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
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:
+39
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user