sites: resolve the request host at the point of use, so a published site serves the site
Hanzo CI/CD / cicd (push) Successful in 19s
CI/CD / gate (push) Successful in 19s
CI/CD / containment (push) Successful in 2m29s
CI/CD / image (push) Failing after 11m52s
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped

Every published site served the CONSOLE, and mounted the whole cloud API under
the customer's own hostname. Measured live 2026-08-03:

  quest.hanzo.app/                    -> <title>Hanzo Cloud Console
  quest.hanzo.app/v1/billing/plans    -> 200

fiber parses the request URI once, and behind the ingress the parsed host is
empty — so siteSlug("") failed, customCandidate("") failed, and every request
fell through to c.Continue() into the API pipeline. The site edge was mounted and
configured correctly the whole time; it just never learned which host was asked
for. Same accessor and same failure as commerce's tenant resolver, in a second
codebase, the same night.

The parsed host still WINS whenever it names something this server serves — that
ordering is the security property, not a detail, because the host picks the ORG
here and a client able to override a real host could serve itself another
tenant's site. X-Forwarded-Host is consulted only when the parsed host is not a
host we can serve, which is the ingress case and never a direct request.
TestMiddlewareTenantKeyedByHostNotPath (pre-existing, unchanged) still passes.

Negative-controlled: reverting to Hostname() alone fails the new test with the
fall-through this commit is named for. One correction on the way: the first
version of the new test used req.Host="" to express "no parsed host" — httptest
synthesizes "localhost" for that, so it proved nothing until measured.
This commit is contained in:
antje
2026-08-03 11:00:54 -07:00
parent b66c9a2576
commit 8b729f8ef1
2 changed files with 87 additions and 1 deletions
+39 -1
View File
@@ -298,9 +298,47 @@ func analyticsIngest(c *zip.Ctx) (func(org string, c *zip.Ctx) error, bool) {
return h, ok && h != nil
}
// requestHost is the ONE way this server learns which host was asked for.
//
// fiber parses the request URI once, and behind the ingress the parsed host is
// EMPTY — so Hostname() alone resolved nothing, every published site fell
// through to c.Continue(), and <slug>.hanzo.app served the console SPA with the
// whole cloud API mounted under a customer's own hostname. Measured 2026-08-03:
// quest.hanzo.app returned <title>Hanzo Cloud Console and
// quest.hanzo.app/v1/billing/plans returned 200. Same accessor, same failure as
// commerce's tenant resolver earlier the same night.
//
// The parsed host ALWAYS wins. X-Forwarded-Host is consulted only when there is
// no parsed host at all, which is exactly the ingress case and never a direct
// request. That ordering is the security property, not a detail: the host picks
// the ORG here, so a client that could override a real host could serve itself
// another tenant's site. TestMiddlewareTenantKeyedByHostNotPath pins it — a
// request that HAS a host ignores the header completely.
func (s *Server) requestHost(c *zip.Ctx) string {
// A parsed host that names a site (or a bindable custom domain) is the
// truth and is never overridden. Anything else — empty behind the ingress,
// or the ingress' own service name — is not a host this server can serve,
// so the forwarded name is the only candidate left.
parsed := hostOnly(c.Fiber().Hostname())
if parsed != "" {
if _, _, ok := s.siteSlug(parsed); ok {
return parsed
}
if s.customCandidate(parsed) {
return parsed
}
}
// Left-most entry: proxies append, so the first is the client-facing name.
fwd := c.Header("X-Forwarded-Host")
if i := strings.IndexByte(fwd, ','); i >= 0 {
fwd = fwd[:i]
}
return hostOnly(fwd)
}
func (s *Server) Middleware() zip.Handler {
return func(c *zip.Ctx) error {
raw := c.Fiber().Hostname()
raw := s.requestHost(c)
if slug, firstParty, ok := s.siteSlug(raw); ok {
if baseHostHandler != nil && isBasePath(c.Path()) {
if site, ok := s.resolveLivePinned(c.Context(), slug, firstParty); ok {
+48
View File
@@ -604,3 +604,51 @@ func TestNotFoundAnswersDataRequestsInType(t *testing.T) {
}
}
}
// Behind the ingress the parsed URI host is EMPTY and the only carrier of the
// customer-facing name is X-Forwarded-Host. Without this, siteSlug("") failed,
// customCandidate("") failed, and every published site fell through to the API
// pipeline — <slug>.hanzo.app served the console SPA and mounted the whole cloud
// API under a customer's own hostname (measured live 2026-08-03).
func TestMiddlewareResolvesFromForwardedHostWhenParsedHostIsEmpty(t *testing.T) {
fr := &fakeResolver{found: false}
SetResolver(fr)
defer SetResolver(nil)
app := newTestApp(testServer())
// The shape behind the ingress: the parsed host is not a site host (the
// ingress' own name), and the customer-facing name rides X-Forwarded-Host.
// NOTE httptest synthesizes "localhost" for an empty Host, so an empty
// string cannot be used to express "no parsed host" — measured, not assumed.
req := httptest.NewRequest("GET", "http://localhost/index.html", nil)
req.Header.Set("X-Forwarded-Host", "quest.hanzo.app")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("test: %v", err)
}
if resp.Header.Get("X-Sentinel") == "hit" {
t.Fatal("a published site fell through to the API pipeline — this is the console-instead-of-site defect")
}
if got := fr.slugs(); len(got) != 1 || got[0] != "quest" {
t.Fatalf("resolver called with %v, want exactly [quest]", got)
}
}
// ...and the fallback must never become an override. A request that HAS a host
// ignores the header completely — the host picks the ORG, so a client able to
// override a real host could serve itself another tenant's site.
func TestMiddlewareForwardedHostNeverOverridesARealHost(t *testing.T) {
fr := &fakeResolver{found: false}
SetResolver(fr)
defer SetResolver(nil)
app := newTestApp(testServer())
req := httptest.NewRequest("GET", "http://victim.hanzo.app/index.html", nil)
req.Header.Set("X-Forwarded-Host", "attacker.hanzo.app")
if _, err := app.Fiber().Test(req); err != nil {
t.Fatalf("test: %v", err)
}
if got := fr.slugs(); len(got) != 1 || got[0] != "victim" {
t.Fatalf("resolver called with %v, want exactly [victim] — the header must not override a real host", got)
}
}