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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-138 option C (operator ruling 2026-10-08, `09` §3 decision 190): a pasted Cloudflare API token is saved only when
|
||||
// Cloudflare says it sees exactly this customer's own zone. A fake Cloudflare (httptest) stands in for the API; no
|
||||
// real call is made. The CONSEQUENCE asserted is the stored config: a refused token is not stored, and on edit the
|
||||
// previous token stays.
|
||||
//
|
||||
// RED-PROOF: remove the cfTokenReachMessage block from handleConfigCreate → TestR138_CreateRefusesWideToken stores
|
||||
// customer „b" with the account-wide token → FAILS.
|
||||
|
||||
const (
|
||||
tokOwn = "tok-own-zone-0123456789abcdef"
|
||||
tokWide = "tok-account-wide-0123456789abcdef"
|
||||
tokOther = "tok-other-zone-0123456789abcdef"
|
||||
tokNone = "tok-sees-nothing-0123456789abcdef"
|
||||
tokOwn2 = "tok-own-zone-second-0123456789abcdef"
|
||||
)
|
||||
|
||||
type fakeCF struct {
|
||||
srv *httptest.Server
|
||||
calls atomic.Int32
|
||||
down atomic.Bool
|
||||
}
|
||||
|
||||
func newFakeCF(t *testing.T) *fakeCF {
|
||||
f := &fakeCF{}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f.calls.Add(1)
|
||||
if f.down.Load() {
|
||||
http.Error(w, `{"success":false}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/zones" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var zones []string
|
||||
switch strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
case tokOwn, tokOwn2:
|
||||
zones = []string{"example.hu"}
|
||||
case tokWide:
|
||||
zones = []string{"example.hu", "neighbour.hu"}
|
||||
case tokOther:
|
||||
zones = []string{"neighbour.hu"}
|
||||
case tokNone:
|
||||
zones = nil
|
||||
default:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(`{"success":false,"errors":[{"message":"Invalid API Token"}]}`))
|
||||
return
|
||||
}
|
||||
res := []map[string]string{}
|
||||
for i, z := range zones {
|
||||
res = append(res, map[string]string{"id": "z" + string(rune('0'+i)), "name": z})
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "result": res, "result_info": map[string]int{"total_count": len(res)}})
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func r138Server(t *testing.T) (*Server, *fakeCF, *bytes.Buffer) {
|
||||
s, _ := newTestServer(t)
|
||||
f := newFakeCF(t)
|
||||
s.cfAPIBase = f.srv.URL
|
||||
var logs bytes.Buffer
|
||||
s.logger = log.New(&logs, "", 0)
|
||||
return s, f, &logs
|
||||
}
|
||||
|
||||
func r138Create(s *Server, id, domain, token string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"customer_id": {id}, "customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}, "cf_api_token": {token}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/configs/new", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigCreate(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func r138Edit(s *Server, id, domain, token string) *httptest.ResponseRecorder {
|
||||
form := url.Values{"customer_name": {"T " + id}, "email": {"t@example.org"}, "domain": {domain}, "cf_api_token": {token}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/configs/"+id, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
s.handleConfigUpdate(rr, req, id)
|
||||
return rr
|
||||
}
|
||||
|
||||
func r138StoredToken(t *testing.T, s *Server, id string) (string, bool) {
|
||||
t.Helper()
|
||||
cfg, _ := s.store.GetCustomerConfig(id)
|
||||
if cfg == nil {
|
||||
return "", false
|
||||
}
|
||||
return storedCFToken(cfg.ConfigJSON), true
|
||||
}
|
||||
|
||||
func TestR138_CreateAcceptsOwnZoneToken(t *testing.T) {
|
||||
s, f, _ := r138Server(t)
|
||||
if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("own-zone token must be accepted; got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if tok, ok := r138StoredToken(t, s, "a"); !ok || tok != tokOwn {
|
||||
t.Fatalf("the own-zone token was not stored (stored=%v)", ok)
|
||||
}
|
||||
if f.calls.Load() != 1 {
|
||||
t.Errorf("expected exactly 1 Cloudflare call, got %d", f.calls.Load())
|
||||
}
|
||||
// A domain UNDER the token's one zone is the customer's own too (the zone is the customer's, `01` §7).
|
||||
s2, _, _ := r138Server(t)
|
||||
if rr := r138Create(s2, "sub", "home.example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Errorf("a domain under the token's one zone must be accepted; got %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_CreateRefusesWideToken(t *testing.T) {
|
||||
s, _, _ := r138Server(t)
|
||||
cases := []struct{ id, tok, want string }{
|
||||
{"b", tokWide, "reaches 2 zones"},
|
||||
{"c", tokOther, "neighbour.hu"},
|
||||
{"d", tokNone, "sees no zone"},
|
||||
{"e", "tok-rejected-by-cloudflare-0123456789", "could not be asked"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rr := r138Create(s, c.id, "example.hu", c.tok)
|
||||
if rr.Code == http.StatusSeeOther {
|
||||
t.Errorf("%s: token was accepted — it must be refused", c.id)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), c.want) {
|
||||
t.Errorf("%s: the refusal must say %q; got %d", c.id, c.want, rr.Code)
|
||||
}
|
||||
if _, ok := r138StoredToken(t, s, c.id); ok {
|
||||
t.Errorf("%s: a config was stored for a refused token", c.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_EditRefusedTokenKeepsOldToken(t *testing.T) {
|
||||
s, f, _ := r138Server(t)
|
||||
if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create: %d", rr.Code)
|
||||
}
|
||||
// A wide token on edit: refused, old token stays.
|
||||
if rr := r138Edit(s, "a", "example.hu", tokWide); !strings.Contains(rr.Body.String(), "Nothing was saved") {
|
||||
t.Errorf("wide token on edit must be refused; got %d", rr.Code)
|
||||
}
|
||||
if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn {
|
||||
t.Errorf("after a refused edit the previous token must stay")
|
||||
}
|
||||
// Cloudflare down: refused, old token stays.
|
||||
f.down.Store(true)
|
||||
if rr := r138Edit(s, "a", "example.hu", tokOwn2); !strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("Cloudflare down must refuse with the clear sentence; got %d", rr.Code)
|
||||
}
|
||||
if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn {
|
||||
t.Errorf("while Cloudflare is down the previous token must stay")
|
||||
}
|
||||
// Unreachable (server closed): refused too.
|
||||
f.down.Store(false)
|
||||
f.srv.Close()
|
||||
if rr := r138Edit(s, "a", "example.hu", tokOwn2); !strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("unreachable Cloudflare must refuse; got %d", rr.Code)
|
||||
}
|
||||
if tok, _ := r138StoredToken(t, s, "a"); tok != tokOwn {
|
||||
t.Errorf("while Cloudflare is unreachable the previous token must stay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_UnchangedOrEmptyTokenMakesNoCall(t *testing.T) {
|
||||
s, f, _ := r138Server(t)
|
||||
if rr := r138Create(s, "a", "example.hu", tokOwn); rr.Code != http.StatusSeeOther {
|
||||
t.Fatalf("create: %d", rr.Code)
|
||||
}
|
||||
before := f.calls.Load()
|
||||
f.down.Store(true) // any call would now refuse — so a successful save proves no call was needed
|
||||
if rr := r138Edit(s, "a", "example.hu", tokOwn); strings.Contains(rr.Body.String(), "Nothing was saved") || strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("an unchanged token on an unchanged domain must not be checked; got %s", rr.Body.String())
|
||||
}
|
||||
if f.calls.Load() != before {
|
||||
t.Errorf("unchanged token made %d Cloudflare call(s)", f.calls.Load()-before)
|
||||
}
|
||||
// Empty token (HTTP-01): no call, on create and on edit.
|
||||
if rr := r138Create(s, "h", "http01.hu", ""); rr.Code != http.StatusSeeOther {
|
||||
t.Errorf("empty token create must be accepted; got %d", rr.Code)
|
||||
}
|
||||
if rr := r138Edit(s, "a", "example.hu", ""); strings.Contains(rr.Body.String(), "previous token stays") {
|
||||
t.Errorf("clearing the token must not be checked")
|
||||
}
|
||||
if f.calls.Load() != before {
|
||||
t.Errorf("empty token made %d Cloudflare call(s)", f.calls.Load()-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestR138_TokenNeverInLogsOrPage(t *testing.T) {
|
||||
s, f, logs := r138Server(t)
|
||||
var pages strings.Builder
|
||||
for _, tok := range []string{tokOwn, tokWide, tokOther, tokNone} {
|
||||
rr := r138Create(s, "x"+tok[4:8], "example.hu", tok)
|
||||
if rr.Code != http.StatusSeeOther {
|
||||
pages.WriteString(rr.Body.String())
|
||||
}
|
||||
}
|
||||
f.down.Store(true)
|
||||
pages.WriteString(r138Create(s, "y", "example.hu", tokOwn2).Body.String())
|
||||
for _, tok := range []string{tokOwn, tokWide, tokOther, tokNone, tokOwn2} {
|
||||
if strings.Contains(logs.String(), tok) {
|
||||
t.Errorf("a token appeared in the log")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs.String(), "cloudflare token check") {
|
||||
t.Errorf("positive control: the check must log its outcome; log was %q", logs.String())
|
||||
}
|
||||
// The form echoes what the operator typed (as it always has), but the refusal SENTENCE never carries the token.
|
||||
for _, line := range strings.Split(pages.String(), "\n") {
|
||||
if strings.Contains(line, "Nothing was saved") || strings.Contains(line, "previous token stays") {
|
||||
for _, tok := range []string{tokWide, tokOther, tokNone, tokOwn2} {
|
||||
if strings.Contains(line, tok) {
|
||||
t.Errorf("a refusal sentence carried the token")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,8 @@ type Server struct {
|
||||
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
|
||||
// save. nil in production.
|
||||
beforeCreateSave func(customerID string)
|
||||
// cfAPIBase is a TEST seam for the Cloudflare token reach check (R-138 option C): "" = the real API.
|
||||
cfAPIBase string
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
|
||||
Reference in New Issue
Block a user