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 ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user