From b7f8d48e38f368dc9c6c145ec0ae4170b8bf90bc Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Fri, 21 Aug 2026 06:15:09 +0000 Subject: [PATCH 1/2] feat: redirect anonymous GET / to the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers hitting the server root previously fell through to the S3 handler, which returns a 403 AccessDenied XML for an unauthenticated ListBuckets — a dead end for anyone typing the server URL. Root GETs are now redirected to the panel (/onboarding, /login, or /dashboard). S3 access is unaffected. The redirect only fires for a bare GET / with no SigV4 Authorization header, no X-Amz-* presigned query params, and no host-style {bucket}.{hostBase} subdomain — every genuine S3 client request carries at least one of those markers, and all pass through untouched. --- cmd/s3-server/main.go | 5 ++ internal/handlers/handlers_root_redirect.go | 64 +++++++++++++++ .../handlers/handlers_root_redirect_test.go | 77 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 internal/handlers/handlers_root_redirect.go create mode 100644 internal/handlers/handlers_root_redirect_test.go diff --git a/cmd/s3-server/main.go b/cmd/s3-server/main.go index 57c2316..a0e67a3 100644 --- a/cmd/s3-server/main.go +++ b/cmd/s3-server/main.go @@ -470,6 +470,11 @@ func runServe(c *cli.Context) error { s3Root, cancel = ssl.BucketCreateMiddleware(s3Handler, sslMgr, cfg.S3.HostBases, log) defer cancel() } + + // Redirect anonymous GET / to the panel. Authenticated, presigned, and + // virtual-host style S3 requests still reach the S3 handler. + s3Root = handlers.RootToPanelRedirect(s3Root, cfg.S3.HostBases) + mux.Handle("/", s3Root) // Everything else goes to S3 var httpServer, httpsServer *http.Server diff --git a/internal/handlers/handlers_root_redirect.go b/internal/handlers/handlers_root_redirect.go new file mode 100644 index 0000000..ef2a0c2 --- /dev/null +++ b/internal/handlers/handlers_root_redirect.go @@ -0,0 +1,64 @@ +package handlers + +import ( + "net" + "net/http" + "strings" +) + +// RootToPanelRedirect wraps the S3 handler so that an anonymous GET to the +// server root ("/") is redirected to the panel, while all genuine S3 requests +// are passed through untouched. +// +// A root-level GET is the S3 ListBuckets operation, which requires SigV4 +// authentication (it returns AccessDenied for anonymous requests). Browsers +// never sign requests, so an unauthenticated GET / can only be a user +// navigating to the server URL. Real S3 clients always supply an auth marker, +// so none of them are affected by the redirect. +func RootToPanelRedirect(next http.Handler, hostBases []string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/" && !isS3RootRequest(r, hostBases) { + http.Redirect(w, r, "/_panel/", http.StatusFound) + return + } + next.ServeHTTP(w, r) + }) +} + +// isS3RootRequest reports whether a bare GET / should be treated as an S3 API +// request rather than a browser navigation: +// - authenticated requests (SigV4 Authorization header) +// - presigned requests (X-Amz-* query parameters) +// - virtual-host style requests addressed to a bucket subdomain +// ({bucket}.{hostBase}), which serve bucket object listings at "/". +func isS3RootRequest(r *http.Request, hostBases []string) bool { + if r.Header.Get("Authorization") != "" { + return true + } + for key := range r.URL.Query() { + if strings.HasPrefix(key, "X-Amz-") { + return true + } + } + return hostMatchesBucketBase(r.Host, hostBases) +} + +// hostMatchesBucketBase reports whether host addresses a bucket as a subdomain +// of one of the configured host bucket bases, mirroring the bucket-from-host +// logic used by the S3 handler. +func hostMatchesBucketBase(host string, hostBases []string) bool { + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + for _, base := range hostBases { + suffix := "." + strings.Trim(base, ".") + if !strings.HasSuffix(host, suffix) { + continue + } + bucket := host[:len(host)-len(suffix)] + if bucket != "" && !strings.Contains(bucket, ".") { + return true + } + } + return false +} diff --git a/internal/handlers/handlers_root_redirect_test.go b/internal/handlers/handlers_root_redirect_test.go new file mode 100644 index 0000000..4425c9a --- /dev/null +++ b/internal/handlers/handlers_root_redirect_test.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRootToPanelRedirect(t *testing.T) { + hostBases := []string{"s3.example.com"} + + // next records whether it was reached (i.e. request passed through to S3). + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("s3")) + }) + h := RootToPanelRedirect(next, hostBases) + + run := func(method, target, host string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, nil) + req.Host = host + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + t.Run("anonymous root GET redirects to panel", func(t *testing.T) { + rec := run(http.MethodGet, "/", "example.com") + require.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "/_panel/", rec.Header().Get("Location")) + }) + + t.Run("authenticated root GET passes through", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Host = "example.com" + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAEXAMPLE/20260101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "s3", rec.Body.String()) + }) + + t.Run("presigned root GET passes through", func(t *testing.T) { + rec := run(http.MethodGet, + "/?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAEXAMPLE/20260101/us-east-1/s3/aws4_request&X-Amz-Signature=abc", + "example.com") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "s3", rec.Body.String()) + }) + + t.Run("host-style bucket root GET passes through", func(t *testing.T) { + rec := run(http.MethodGet, "/", "mybucket.s3.example.com") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "s3", rec.Body.String()) + }) + + t.Run("root GET on host base apex redirects to panel", func(t *testing.T) { + rec := run(http.MethodGet, "/", "s3.example.com") + assert.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "/_panel/", rec.Header().Get("Location")) + }) + + t.Run("non-root path passes through", func(t *testing.T) { + rec := run(http.MethodGet, "/mybucket", "example.com") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "s3", rec.Body.String()) + }) + + t.Run("non-GET root passes through", func(t *testing.T) { + rec := run(http.MethodHead, "/", "example.com") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "s3", rec.Body.String()) + }) +} From 4d3a16d9d30110715443756afbba2b5c977a6b19 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Fri, 21 Aug 2026 06:24:09 +0000 Subject: [PATCH 2/2] fix: treat localhost as an implicit host bucket base --- internal/handlers/handlers_root_redirect.go | 10 ++++++++-- internal/handlers/handlers_root_redirect_test.go | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/handlers/handlers_root_redirect.go b/internal/handlers/handlers_root_redirect.go index ef2a0c2..fc64308 100644 --- a/internal/handlers/handlers_root_redirect.go +++ b/internal/handlers/handlers_root_redirect.go @@ -3,6 +3,7 @@ package handlers import ( "net" "net/http" + "slices" "strings" ) @@ -44,12 +45,17 @@ func isS3RootRequest(r *http.Request, hostBases []string) bool { } // hostMatchesBucketBase reports whether host addresses a bucket as a subdomain -// of one of the configured host bucket bases, mirroring the bucket-from-host -// logic used by the S3 handler. +// of one of the effective host bucket bases, mirroring the bucket-from-host +// logic used by the S3 handler. Like s3d, "localhost" is always treated as a +// base so virtual-host-style requests work out of the box during local +// development. func hostMatchesBucketBase(host string, hostBases []string) bool { if h, _, err := net.SplitHostPort(host); err == nil { host = h } + if !slices.Contains(hostBases, "localhost") { + hostBases = append(hostBases, "localhost") + } for _, base := range hostBases { suffix := "." + strings.Trim(base, ".") if !strings.HasSuffix(host, suffix) { diff --git a/internal/handlers/handlers_root_redirect_test.go b/internal/handlers/handlers_root_redirect_test.go index 4425c9a..e6c5678 100644 --- a/internal/handlers/handlers_root_redirect_test.go +++ b/internal/handlers/handlers_root_redirect_test.go @@ -63,6 +63,12 @@ func TestRootToPanelRedirect(t *testing.T) { assert.Equal(t, "/_panel/", rec.Header().Get("Location")) }) + t.Run("host-style localhost bucket root GET passes through", func(t *testing.T) { + rec := run(http.MethodGet, "/", "mybucket.localhost") + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "s3", rec.Body.String()) + }) + t.Run("non-root path passes through", func(t *testing.T) { rec := run(http.MethodGet, "/mybucket", "example.com") assert.Equal(t, http.StatusOK, rec.Code)