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:
@@ -0,0 +1,86 @@
|
||||
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]")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package cloudflare
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZoneCovers(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
zone, domain string
|
||||
want bool
|
||||
}{
|
||||
{"example.hu", "example.hu", true},
|
||||
{"example.hu", "felhom.example.hu", true},
|
||||
{"Example.HU.", "home.example.hu", true},
|
||||
{"example.hu", "notexample.hu", false},
|
||||
{"example.hu", "example.hu.evil.hu", false},
|
||||
{"home.example.hu", "example.hu", false},
|
||||
{"", "example.hu", false},
|
||||
{"example.hu", "", false},
|
||||
} {
|
||||
if got := ZoneCovers(c.zone, c.domain); got != c.want {
|
||||
t.Errorf("ZoneCovers(%q,%q)=%v want %v", c.zone, c.domain, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The count is Cloudflare's total_count, so a token that sees more zones than one page holds is not read as „one".
|
||||
func TestTokenZones_CountsBeyondOnePage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte(`{"success":true,"result":[{"id":"1","name":"a.hu"}],"result_info":{"total_count":7}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
names, total, err := TokenZones(context.Background(), srv.URL, "tok-secret-value-123")
|
||||
if err != nil || total != 7 || len(names) != 1 {
|
||||
t.Fatalf("got names=%v total=%d err=%v; want 1 name, total 7", names, total, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenZones_ErrorsAreUnknownAndCarryNoToken(t *testing.T) {
|
||||
const tok = "tok-secret-value-123"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer "+tok {
|
||||
t.Errorf("token not sent as a bearer header")
|
||||
}
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
w.Write([]byte(`echo ` + tok))
|
||||
}))
|
||||
_, _, err := TokenZones(context.Background(), srv.URL, tok)
|
||||
if !errors.Is(err, ErrReachUnknown) || strings.Contains(err.Error(), tok) {
|
||||
t.Fatalf("5xx: want ErrReachUnknown without the token; got %v", err)
|
||||
}
|
||||
srv.Close()
|
||||
_, _, err = TokenZones(context.Background(), srv.URL, tok)
|
||||
if !errors.Is(err, ErrReachUnknown) || strings.Contains(err.Error(), tok) {
|
||||
t.Fatalf("unreachable: want ErrReachUnknown without the token; got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user