From 700a2d06fef2b2f01ab449c3f2b4a3d117011024 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 14:25:58 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/internal/cloudflare/reach.go | 86 +++++++ hub/internal/cloudflare/reach_test.go | 62 +++++ hub/internal/web/configs.go | 76 ++++++ hub/internal/web/r138_cf_token_reach_test.go | 235 +++++++++++++++++++ hub/internal/web/server.go | 2 + 5 files changed, 461 insertions(+) create mode 100644 hub/internal/cloudflare/reach.go create mode 100644 hub/internal/cloudflare/reach_test.go create mode 100644 hub/internal/web/r138_cf_token_reach_test.go diff --git a/hub/internal/cloudflare/reach.go b/hub/internal/cloudflare/reach.go new file mode 100644 index 00000000..45865f13 --- /dev/null +++ b/hub/internal/cloudflare/reach.go @@ -0,0 +1,86 @@ +package cloudflare + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// R-138 option C (operator ruling 2026-10-08, `09` §3 decision 190): before a customer's Cloudflare API token is saved, +// the hub asks Cloudflare which zones that token can see, and saves it only when it sees exactly the customer's own zone. +// A token minted with account scope by mistake would otherwise let one box rewrite every household's DNS (all customer +// zones sit in one Cloudflare account). + +// ErrReachUnknown wraps every failure to LEARN the token's reach (network, timeout, non-2xx, unparsable answer, a +// rejected token). The caller refuses the save on it — fail closed: an unchecked key never reaches a box. +var ErrReachUnknown = errors.New("cloudflare: the token's reach could not be read") + +// TokenZones lists the names of the zones the token can see (GET /zones). base "" = the real API. The token is sent +// only in the Authorization header and never appears in a returned error. The count is Cloudflare's own +// result_info.total_count when given, so a token that sees more zones than one page holds is still counted right. +func TokenZones(ctx context.Context, base, token string) (names []string, total int, err error) { + if base == "" { + base = apiBase + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSuffix(base, "/")+"/zones?per_page=50", nil) + if err != nil { + return nil, 0, fmt.Errorf("%w: build request", ErrReachUnknown) + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + // The error text names the URL only; the token lives in a header. Redact anyway (defence in depth). + return nil, 0, fmt.Errorf("%w: %s", ErrReachUnknown, redact(err.Error(), token)) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, 0, fmt.Errorf("%w: read answer", ErrReachUnknown) + } + var body struct { + Success bool `json:"success"` + Result []zone `json:"result"` + ResultInfo *struct { + TotalCount int `json:"total_count"` + } `json:"result_info"` + } + if resp.StatusCode/100 != 2 { + return nil, 0, fmt.Errorf("%w: HTTP %d", ErrReachUnknown, resp.StatusCode) + } + if err := json.Unmarshal(data, &body); err != nil || !body.Success { + return nil, 0, fmt.Errorf("%w: unparsable or unsuccessful answer (HTTP %d)", ErrReachUnknown, resp.StatusCode) + } + for _, z := range body.Result { + names = append(names, z.Name) + } + total = len(names) + if body.ResultInfo != nil && body.ResultInfo.TotalCount > total { + total = body.ResultInfo.TotalCount + } + return names, total, nil +} + +// ZoneCovers reports whether a customer domain is the zone itself or a name under it, on a label boundary +// („notexample.hu" is not under „example.hu"). Case and a trailing dot are ignored. +func ZoneCovers(zoneName, domain string) bool { + z := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zoneName)), ".") + d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(domain)), ".") + if z == "" || d == "" { + return false + } + return d == z || strings.HasSuffix(d, "."+z) +} + +func redact(s, token string) string { + if token == "" { + return s + } + return strings.ReplaceAll(s, token, "[redacted]") +} diff --git a/hub/internal/cloudflare/reach_test.go b/hub/internal/cloudflare/reach_test.go new file mode 100644 index 00000000..821b1fba --- /dev/null +++ b/hub/internal/cloudflare/reach_test.go @@ -0,0 +1,62 @@ +package cloudflare + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestZoneCovers(t *testing.T) { + for _, c := range []struct { + zone, domain string + want bool + }{ + {"example.hu", "example.hu", true}, + {"example.hu", "felhom.example.hu", true}, + {"Example.HU.", "home.example.hu", true}, + {"example.hu", "notexample.hu", false}, + {"example.hu", "example.hu.evil.hu", false}, + {"home.example.hu", "example.hu", false}, + {"", "example.hu", false}, + {"example.hu", "", false}, + } { + if got := ZoneCovers(c.zone, c.domain); got != c.want { + t.Errorf("ZoneCovers(%q,%q)=%v want %v", c.zone, c.domain, got, c.want) + } + } +} + +// The count is Cloudflare's total_count, so a token that sees more zones than one page holds is not read as „one". +func TestTokenZones_CountsBeyondOnePage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"success":true,"result":[{"id":"1","name":"a.hu"}],"result_info":{"total_count":7}}`)) + })) + defer srv.Close() + names, total, err := TokenZones(context.Background(), srv.URL, "tok-secret-value-123") + if err != nil || total != 7 || len(names) != 1 { + t.Fatalf("got names=%v total=%d err=%v; want 1 name, total 7", names, total, err) + } +} + +func TestTokenZones_ErrorsAreUnknownAndCarryNoToken(t *testing.T) { + const tok = "tok-secret-value-123" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+tok { + t.Errorf("token not sent as a bearer header") + } + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`echo ` + tok)) + })) + _, _, err := TokenZones(context.Background(), srv.URL, tok) + if !errors.Is(err, ErrReachUnknown) || strings.Contains(err.Error(), tok) { + t.Fatalf("5xx: want ErrReachUnknown without the token; got %v", err) + } + srv.Close() + _, _, err = TokenZones(context.Background(), srv.URL, tok) + if !errors.Is(err, ErrReachUnknown) || strings.Contains(err.Error(), tok) { + t.Fatalf("unreachable: want ErrReachUnknown without the token; got %v", err) + } +} diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 297eb544..bc1a1a5b 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -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. 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 "" +} diff --git a/hub/internal/web/r138_cf_token_reach_test.go b/hub/internal/web/r138_cf_token_reach_test.go new file mode 100644 index 00000000..4a5ca83a --- /dev/null +++ b/hub/internal/web/r138_cf_token_reach_test.go @@ -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") + } + } + } + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 16500347..436cfc95 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -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.