hub: check a pasted Cloudflare token's reach before saving it (R-138 option C, decision 190)
On create and edit, a non-empty cf_api_token is checked with Cloudflare (GET /zones): it is saved only when the token sees exactly one zone and the customer's domain is that zone or a name under it. More zones, another zone, no zone, or Cloudflare not answering -> the form re-renders with one sentence and nothing is saved (the previous token stays). An unchanged token on an unchanged domain and an empty token (HTTP-01) make no call. The token is never logged and never in a sentence or error. Tests use a fake Cloudflare (httptest). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -752,6 +752,18 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}, nil, msg)
|
||||
return
|
||||
}
|
||||
// R-138 option C (decision 190): a pasted Cloudflare API token must reach only this customer's own zone.
|
||||
if msg := s.cfTokenReachMessage(r.Context(), customerID, r.FormValue("domain"), r.FormValue("cf_api_token")); msg != "" {
|
||||
var submitted map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
|
||||
s.renderConfigForm(w, r, true, &store.CustomerConfig{
|
||||
CustomerID: customerID,
|
||||
CustomerName: r.FormValue("customer_name"),
|
||||
Domain: r.FormValue("domain"),
|
||||
Email: r.FormValue("email"),
|
||||
}, submitted, msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate credentials.
|
||||
//
|
||||
@@ -853,6 +865,8 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
|
||||
}
|
||||
|
||||
prevEmail := cfg.Email
|
||||
prevDomain := cfg.Domain
|
||||
prevCFToken := storedCFToken(cfg.ConfigJSON)
|
||||
cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name"))
|
||||
cfg.Domain = strings.TrimSpace(r.FormValue("domain"))
|
||||
cfg.Email = strings.TrimSpace(r.FormValue("email"))
|
||||
@@ -880,6 +894,17 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
|
||||
s.renderConfigForm(w, r, false, cfg, submitted, msg)
|
||||
return
|
||||
}
|
||||
// R-138 option C (decision 190): a NEW token, or the same token moved to a new domain, is checked against
|
||||
// Cloudflare; an unchanged token on an unchanged domain is not (no call — editing another field never
|
||||
// depends on Cloudflare). A refusal saves nothing, so the previous token stays.
|
||||
if newTok := strings.TrimSpace(r.FormValue("cf_api_token")); newTok != prevCFToken || !strings.EqualFold(normDomainForm(cfg.Domain), normDomainForm(prevDomain)) {
|
||||
if msg := s.cfTokenReachMessage(r.Context(), customerID, cfg.Domain, newTok); msg != "" {
|
||||
var submitted map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
|
||||
s.renderConfigForm(w, r, false, cfg, submitted, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfg.ConfigJSON = buildConfigJSON(r)
|
||||
|
||||
@@ -1807,3 +1832,54 @@ func (s *Server) domainConflictMessage(customerID, domain string) string {
|
||||
return fmt.Sprintf("Domain %q equals, contains or lies under the domain of customer %q — every customer has their own domain (01 §7). Nothing was saved.", strings.TrimSpace(domain), other)
|
||||
}
|
||||
}
|
||||
|
||||
// storedCFToken reads infrastructure.cf_api_token from a stored config_json ("" when absent or unparsable).
|
||||
func storedCFToken(configJSON string) string {
|
||||
var overrides map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(configJSON), &overrides); err != nil {
|
||||
return ""
|
||||
}
|
||||
if infra, ok := overrides["infrastructure"].(map[string]interface{}); ok {
|
||||
v, _ := infra["cf_api_token"].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normDomainForm(d string) string { return strings.TrimSuffix(strings.TrimSpace(d), ".") }
|
||||
|
||||
// cfTokenReachMessage is the operator's sentence for a refused Cloudflare API token ("" = allowed). R-138 option C
|
||||
// (operator ruling 2026-10-08, `09` §3 decision 190): the hub asks Cloudflare which zones the token can see and allows
|
||||
// it only when it sees EXACTLY ONE zone and the customer's domain is that zone or a name under it (`01` §7: every
|
||||
// customer has their own domain — the zone is the customer's, so a dashboard name like felhom.<domain> under it is
|
||||
// fine; a second zone is someone else's). An empty token (HTTP-01) is never checked. Fail closed: when Cloudflare
|
||||
// cannot be asked, the save is refused and nothing changes. The token is never logged and never in a sentence.
|
||||
// Pinned by TestR138_* (r138_cf_token_reach_test.go).
|
||||
func (s *Server) cfTokenReachMessage(ctx context.Context, customerID, domain, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
domain = strings.TrimSpace(domain)
|
||||
names, total, err := cfClient.TokenZones(ctx, s.cfAPIBase, token)
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] cloudflare token check for %s: Cloudflare could not be asked (%v) — save refused", customerID, err)
|
||||
return "Cloudflare could not be asked what this API token can reach, so it was not checked — nothing was saved and the previous token stays. Try again in a few minutes."
|
||||
}
|
||||
switch {
|
||||
case total == 0:
|
||||
s.logger.Printf("[WARN] cloudflare token check for %s: the token sees 0 zones — save refused", customerID)
|
||||
return fmt.Sprintf("The Cloudflare API token sees no zone — it must be able to read exactly this customer's own zone (%q). Nothing was saved.", domain)
|
||||
case total > 1:
|
||||
s.logger.Printf("[WARN] cloudflare token check for %s: the token sees %d zones — save refused (R-138)", customerID, total)
|
||||
return fmt.Sprintf("The Cloudflare API token reaches %d zones — it must reach only this customer's own zone. Make a token for this one zone (Zone Resources: Include, Specific zone). Nothing was saved.", total)
|
||||
case len(names) != 1:
|
||||
s.logger.Printf("[WARN] cloudflare token check for %s: Cloudflare counted 1 zone but listed %d — save refused", customerID, len(names))
|
||||
return "Cloudflare's answer about this API token was incomplete, so it was not checked — nothing was saved and the previous token stays. Try again in a few minutes."
|
||||
case !cfClient.ZoneCovers(names[0], domain):
|
||||
s.logger.Printf("[WARN] cloudflare token check for %s: the token's one zone %q does not cover domain %q — save refused", customerID, names[0], domain)
|
||||
return fmt.Sprintf("The Cloudflare API token reaches zone %q, which is not this customer's domain %q. Nothing was saved.", names[0], domain)
|
||||
}
|
||||
s.logger.Printf("[INFO] cloudflare token check for %s: the token sees 1 zone (%s), which covers the customer's domain — allowed", customerID, names[0])
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-138 option C (operator ruling 2026-10-08, `09` §3 decision 190): a pasted Cloudflare API token is saved only when
|
||||
// Cloudflare says it sees exactly this customer's own zone. A fake Cloudflare (httptest) stands in for the API; no
|
||||
// real call is made. The CONSEQUENCE asserted is the stored config: a refused token is not stored, and on edit the
|
||||
// previous token stays.
|
||||
//
|
||||
// RED-PROOF: remove the cfTokenReachMessage block from handleConfigCreate → TestR138_CreateRefusesWideToken stores
|
||||
// customer „b" with the account-wide token → FAILS.
|
||||
|
||||
const (
|
||||
tokOwn = "tok-own-zone-0123456789abcdef"
|
||||
tokWide = "tok-account-wide-0123456789abcdef"
|
||||
tokOther = "tok-other-zone-0123456789abcdef"
|
||||
tokNone = "tok-sees-nothing-0123456789abcdef"
|
||||
tokOwn2 = "tok-own-zone-second-0123456789abcdef"
|
||||
)
|
||||
|
||||
type fakeCF struct {
|
||||
srv *httptest.Server
|
||||
calls atomic.Int32
|
||||
down atomic.Bool
|
||||
}
|
||||
|
||||
func newFakeCF(t *testing.T) *fakeCF {
|
||||
f := &fakeCF{}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.calls.Add(1)
|
||||
if f.down.Load() {
|
||||
http.Error(w, `{"success":false}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/zones" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var zones []string
|
||||
switch strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
case tokOwn, tokOwn2:
|
||||
zones = []string{"example.hu"}
|
||||
case tokWide:
|
||||
zones = []string{"example.hu", "neighbour.hu"}
|
||||
case tokOther:
|
||||
zones = []string{"neighbour.hu"}
|
||||
case tokNone:
|
||||
zones = nil
|
||||
default:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"success":false,"errors":[{"message":"Invalid API Token"}]}`))
|
||||
return
|
||||
}
|
||||
res := []map[string]string{}
|
||||
for i, z := range zones {
|
||||
res = append(res, map[string]string{"id": "z" + string(rune('0'+i)), "name": z})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": res, "result_info": map[string]int{"total_count": len(res)}})
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func r138Server(t *testing.T) (*Server, *fakeCF, *bytes.Buffer) {
|
||||
s, _ := newTestServer(t)
|
||||
f := newFakeCF(t)
|
||||
s.cfAPIBase = f.srv.URL
|
||||
var logs bytes.Buffer
|
||||
s.logger = log.New(&logs, "", 0)
|
||||
return s, f, &logs
|
||||
}
|
||||
|
||||
func r138Create(s *Server, id, domain, token string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"customer_id": {id}, "customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}, "cf_api_token": {token}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/configs/new", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigCreate(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func r138Edit(s *Server, id, domain, token string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}, "cf_api_token": {token}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/configs/"+id, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigUpdate(rr, req, id)
|
||||
return rr
|
||||
}
|
||||
|
||||
func r138StoredToken(t *testing.T, s *Server, id string) (string, bool) {
|
||||
t.Helper()
|
||||
cfg, _ := s.store.GetCustomerConfig(id)
|
||||
if cfg == nil {
|
||||
return "", false
|
||||
}
|
||||
return storedCFToken(cfg.ConfigJSON), true
|
||||
}
|
||||
|
||||
func TestR138_CreateAcceptsOwnZoneToken(t *testing.T) {
|
||||
s, f, _ := r138Server(t)
|
||||
if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("own-zone token must be accepted; got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if tok, ok := r138StoredToken(t, s, "a"); !ok || tok != tokOwn {
|
||||
t.Fatalf("the own-zone token was not stored (stored=%v)", ok)
|
||||
}
|
||||
if f.calls.Load() != 1 {
|
||||
t.Errorf("expected exactly 1 Cloudflare call, got %d", f.calls.Load())
|
||||
}
|
||||
// A domain UNDER the token's one zone is the customer's own too (the zone is the customer's, `01` §7).
|
||||
s2, _, _ := r138Server(t)
|
||||
if rr := r138Create(s2, "sub", "home.example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Errorf("a domain under the token's one zone must be accepted; got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_CreateRefusesWideToken(t *testing.T) {
|
||||
s, _, _ := r138Server(t)
|
||||
cases := []struct{ id, tok, want string }{
|
||||
{"b", tokWide, "reaches 2 zones"},
|
||||
{"c", tokOther, "neighbour.hu"},
|
||||
{"d", tokNone, "sees no zone"},
|
||||
{"e", "tok-rejected-by-cloudflare-0123456789", "could not be asked"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rr := r138Create(s, c.id, "example.hu", c.tok)
|
||||
if rr.Code == http.StatusSeeOther {
|
||||
t.Errorf("%s: token was accepted — it must be refused", c.id)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), c.want) {
|
||||
t.Errorf("%s: the refusal must say %q; got %d", c.id, c.want, rr.Code)
|
||||
}
|
||||
if _, ok := r138StoredToken(t, s, c.id); ok {
|
||||
t.Errorf("%s: a config was stored for a refused token", c.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_EditRefusedTokenKeepsOldToken(t *testing.T) {
|
||||
s, f, _ := r138Server(t)
|
||||
if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create: %d", rr.Code)
|
||||
}
|
||||
// A wide token on edit: refused, old token stays.
|
||||
if rr := r138Edit(s, "a", "example.hu", tokWide); !strings.Contains(rr.Body.String(), "Nothing was saved") {
|
||||
t.Errorf("wide token on edit must be refused; got %d", rr.Code)
|
||||
}
|
||||
if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn {
|
||||
t.Errorf("after a refused edit the previous token must stay")
|
||||
}
|
||||
// Cloudflare down: refused, old token stays.
|
||||
f.down.Store(true)
|
||||
if rr := r138Edit(s, "a", "example.hu", tokOwn2); !strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("Cloudflare down must refuse with the clear sentence; got %d", rr.Code)
|
||||
}
|
||||
if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn {
|
||||
t.Errorf("while Cloudflare is down the previous token must stay")
|
||||
}
|
||||
// Unreachable (server closed): refused too.
|
||||
f.down.Store(false)
|
||||
f.srv.Close()
|
||||
if rr := r138Edit(s, "a", "example.hu", tokOwn2); !strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("unreachable Cloudflare must refuse; got %d", rr.Code)
|
||||
}
|
||||
if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn {
|
||||
t.Errorf("while Cloudflare is unreachable the previous token must stay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_UnchangedOrEmptyTokenMakesNoCall(t *testing.T) {
|
||||
s, f, _ := r138Server(t)
|
||||
if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create: %d", rr.Code)
|
||||
}
|
||||
before := f.calls.Load()
|
||||
f.down.Store(true) // any call would now refuse — so a successful save proves no call was needed
|
||||
if rr := r138Edit(s, "a", "example.hu", tokOwn); strings.Contains(rr.Body.String(), "Nothing was saved") || strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("an unchanged token on an unchanged domain must not be checked; got %s", rr.Body.String())
|
||||
}
|
||||
if f.calls.Load() != before {
|
||||
t.Errorf("unchanged token made %d Cloudflare call(s)", f.calls.Load()-before)
|
||||
}
|
||||
// Empty token (HTTP-01): no call, on create and on edit.
|
||||
if rr := r138Create(s, "h", "http01.hu", ""); rr.Code != http.StatusSeeOther {
|
||||
t.Errorf("empty token create must be accepted; got %d", rr.Code)
|
||||
}
|
||||
if rr := r138Edit(s, "a", "example.hu", ""); strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("clearing the token must not be checked")
|
||||
}
|
||||
if f.calls.Load() != before {
|
||||
t.Errorf("empty token made %d Cloudflare call(s)", f.calls.Load()-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_TokenNeverInLogsOrPage(t *testing.T) {
|
||||
s, f, logs := r138Server(t)
|
||||
var pages strings.Builder
|
||||
for _, tok := range []string{tokOwn, tokWide, tokOther, tokNone} {
|
||||
rr := r138Create(s, "x"+tok[4:8], "example.hu", tok)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
pages.WriteString(rr.Body.String())
|
||||
}
|
||||
}
|
||||
f.down.Store(true)
|
||||
pages.WriteString(r138Create(s, "y", "example.hu", tokOwn2).Body.String())
|
||||
for _, tok := range []string{tokOwn, tokWide, tokOther, tokNone, tokOwn2} {
|
||||
if strings.Contains(logs.String(), tok) {
|
||||
t.Errorf("a token appeared in the log")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs.String(), "cloudflare token check") {
|
||||
t.Errorf("positive control: the check must log its outcome; log was %q", logs.String())
|
||||
}
|
||||
// The form echoes what the operator typed (as it always has), but the refusal SENTENCE never carries the token.
|
||||
for _, line := range strings.Split(pages.String(), "\n") {
|
||||
if strings.Contains(line, "Nothing was saved") || strings.Contains(line, "previous token stays") {
|
||||
for _, tok := range []string{tokWide, tokOther, tokNone, tokOwn2} {
|
||||
if strings.Contains(line, tok) {
|
||||
t.Errorf("a refusal sentence carried the token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ type Server struct {
|
||||
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
|
||||
// save. nil in production.
|
||||
beforeCreateSave func(customerID string)
|
||||
// cfAPIBase is a TEST seam for the Cloudflare token reach check (R-138 option C): "" = the real API.
|
||||
cfAPIBase string
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
|
||||
Reference in New Issue
Block a user