Files
felhom.eu/hub/internal/web/r138_cf_token_reach_test.go
T

265 lines
10 KiB
Go

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"
tokParent = "tok-parent-zone-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
case tokParent:
zones = []string{"parent.hu"}
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 refused: the zone must BE the customer's domain (security review
// 2026-10-08 — a zone above the domain could hold a sibling customer added later).
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 refused")
}
}
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")
}
}
}
}
}
// A token whose one zone sits ABOVE the customer's domain is refused — in EITHER order of creation. Security review
// 2026-10-08: checking the zone against today's customers only was order-dependent (a sibling b.parent.hu created
// AFTER a.parent.hu's parent-zone key was stored passes R-415, which compares the two domains only). The zone must
// equal the domain. RED-PROOF: ZoneEquals → suffix match (the old ZoneCovers) → „a" is stored → FAILS.
func TestR138_ParentZoneHoldingAnotherCustomerRefused(t *testing.T) {
for _, order := range []string{"sibling first", "sibling after"} {
s, _, _ := r138Server(t)
if order == "sibling first" {
if rr := r138Create(s, "b", "b.parent.hu", ""); rr.Code != http.StatusSeeOther {
t.Fatalf("setup: customer b without a token must be created; got %d %s", rr.Code, rr.Body.String())
}
}
rr := r138Create(s, "a", "a.parent.hu", tokParent)
if rr.Code == http.StatusSeeOther {
t.Errorf("%s: a token for zone parent.hu was accepted for a.parent.hu", order)
}
if !strings.Contains(rr.Body.String(), "which is not this customer") {
t.Errorf("%s: the refusal must say the zone is not the domain; got %d", order, rr.Code)
}
if _, ok := r138StoredToken(t, s, "a"); ok {
t.Errorf("%s: a config was stored for a refused parent-zone token", order)
}
}
}