From 501312a4d275c8a5a7136ce462e237a16cc26caf Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 8 Oct 2026 14:31:57 +0200 Subject: [PATCH] hub R-138: refuse a token whose one zone also holds another customer's domain (security review) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/internal/web/configs.go | 12 +++++++ hub/internal/web/r138_cf_token_reach_test.go | 38 +++++++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index bc1a1a5b..f3b696b0 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -1880,6 +1880,18 @@ func (s *Server) cfTokenReachMessage(ctx context.Context, customerID, domain, to 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) } + // A zone ABOVE the customer's domain may also hold another customer's sibling domain (a.parent.hu and + // b.parent.hu under zone parent.hu pass the R-415 guard, which compares the two domains only). So the zone + // itself must not equal, contain or lie under any other customer's domain, nor under felhom.eu. Security + // review 2026-10-08; pinned by TestR138_ParentZoneHoldingAnotherCustomerRefused. + if other, err := s.store.DomainConflict(customerID, names[0]); err != nil || other != "" { + if err != nil { + s.logger.Printf("[ERROR] cloudflare token check for %s: the zone could not be checked against the other customers: %v — save refused", customerID, err) + return "The Cloudflare API token's zone could not be checked against the other customers — nothing was saved. Try again." + } + s.logger.Printf("[WARN] cloudflare token check for %s: the token's zone %q also covers customer %s's domain — save refused (R-138)", customerID, names[0], other) + return fmt.Sprintf("The Cloudflare API token reaches zone %q, which also holds the domain of customer %q — make the customer's own domain a zone of its own. Nothing was saved.", names[0], other) + } 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 "" } diff --git a/hub/internal/web/r138_cf_token_reach_test.go b/hub/internal/web/r138_cf_token_reach_test.go index 4a5ca83a..1ccc47e4 100644 --- a/hub/internal/web/r138_cf_token_reach_test.go +++ b/hub/internal/web/r138_cf_token_reach_test.go @@ -21,11 +21,12 @@ import ( // 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" + 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 { @@ -56,6 +57,8 @@ func newFakeCF(t *testing.T) *fakeCF { 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"}]}`)) @@ -233,3 +236,28 @@ func TestR138_TokenNeverInLogsOrPage(t *testing.T) { } } } + +// A token whose one zone sits ABOVE the customer's domain and also holds another customer's sibling domain is +// refused (security review 2026-10-08: the R-415 guard compares the two domains only, and a.parent.hu and +// b.parent.hu do not overlap). Control: the same parent-zone token is accepted when no other customer lives under it. +// RED-PROOF: remove the DomainConflict(zone) block in cfTokenReachMessage → customer „a" is stored → FAILS. +func TestR138_ParentZoneHoldingAnotherCustomerRefused(t *testing.T) { + s, _, _ := r138Server(t) + 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.Fatalf("a token for zone parent.hu, which also holds customer b, was accepted") + } + if !strings.Contains(rr.Body.String(), "also holds the domain of customer") { + t.Errorf("the refusal must name the shared zone; got %d", rr.Code) + } + if _, ok := r138StoredToken(t, s, "a"); ok { + t.Errorf("a config was stored for a refused parent-zone token") + } + s2, _, _ := r138Server(t) + if rr := r138Create(s2, "a", "a.parent.hu", tokParent); rr.Code != http.StatusSeeOther { + t.Errorf("control: the parent-zone token with no other customer under it must be accepted; got %d %s", rr.Code, rr.Body.String()) + } +}