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:
2026-10-08 14:25:58 +02:00
parent e1ff3210eb
commit 700a2d06fe
5 changed files with 461 additions and 0 deletions
+86
View File
@@ -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]")
}
+62
View File
@@ -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)
}
}
+76
View File
@@ -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")
}
}
}
}
}
+2
View File
@@ -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.