700a2d06fe
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
87 lines
3.3 KiB
Go
87 lines
3.3 KiB
Go
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]")
|
|
}
|