Files
felhom.eu/hub/internal/cloudflare/reach.go
T
admin 700a2d06fe 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
2026-10-08 15:17:05 +02:00

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