e6184353db
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
265 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|