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]") }