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
+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 ""
}