fix(s3): keep host-less bucket catch-all so reverse proxies work (#9540)

When s3.domainName is set, all bucket-prefix routes were gated on a
matching Host header. Requests that arrive via an IP, an unlisted
hostname, or a reverse proxy that rewrites Host hit no router and bounce
back as 405/404 (and 503 once a proxy maps the upstream error).

Register the path-style catch-all unconditionally, after the
host-specific routers, so it only fires when no Host matcher applies.
This commit is contained in:
Chris Lu
2026-05-18 19:44:19 -07:00
committed by GitHub
parent d3f80444df
commit 58c3fa802c
2 changed files with 45 additions and 2 deletions
+5 -2
View File
@@ -684,9 +684,12 @@ func (s3a *S3ApiServer) registerRouter(router *mux.Router) {
routers = append(routers, apiRouter.Host(
fmt.Sprintf("%s.%s", "{bucket:.+}", virtualHost)).Subrouter())
}
} else {
routers = append(routers, apiRouter.PathPrefix("/{bucket}").Subrouter())
}
// Always register a Host-less path-style catch-all last so requests that
// arrive via an IP, an unlisted hostname, or a reverse proxy that rewrites
// the Host header still match bucket routes. Host-specific routers above
// take precedence because they were registered first.
routers = append(routers, apiRouter.PathPrefix("/{bucket}").Subrouter())
// Get CORS middleware instance with caching
corsMiddleware := s3a.getCORSMiddleware()
+40
View File
@@ -296,3 +296,43 @@ func TestRouting_IAMMatcherLogic(t *testing.T) {
})
}
}
// TestRouting_BucketRouteMatchesAnyHost is a regression test for issue #9539.
// When DomainName is configured, bucket-prefix routes must still match for
// requests whose Host header does not match a configured domain (for example,
// requests forwarded by a reverse proxy that rewrites Host to an internal
// upstream address). A Host-less path-style catch-all is registered after the
// host-specific routers so it only fires when no Host matcher applies.
func TestRouting_BucketRouteMatchesAnyHost(t *testing.T) {
cases := []struct {
name string
domainName string
host string
}{
{"virtual-host domain configured, request via IP", "s3.example.com", "10.0.0.5:8333"},
{"virtual-host domain configured, request via unrelated host", "s3.example.com", "internal-upstream:8333"},
{"path-style + virtual-host configured, request via IP", "s3.example.com,api.s3.example.com", "10.0.0.5:8333"},
{"domain configured, request via the bare configured host (no bucket subdomain)", "s3.example.com", "s3.example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
router := mux.NewRouter()
s3a := setupRoutingTestServer(t)
s3a.option.DomainName = tc.domainName
s3a.registerRouter(router)
req, _ := http.NewRequest(http.MethodHead, "http://"+tc.host+"/some-bucket", nil)
req.Host = tc.host
var match mux.RouteMatch
if !router.Match(req, &match) {
t.Fatalf("expected HEAD /some-bucket with Host=%q to match a bucket route (domainName=%q); got no match (err=%v)",
tc.host, tc.domainName, match.MatchErr)
}
if match.MatchErr != nil {
t.Fatalf("route matched but reported error: %v", match.MatchErr)
}
})
}
}