Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/s3-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions internal/handlers/handlers_root_redirect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package handlers

import (
"net"
"net/http"
"slices"
"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 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) {
continue
}
bucket := host[:len(host)-len(suffix)]
if bucket != "" && !strings.Contains(bucket, ".") {
return true
Comment thread
kody-ai[bot] marked this conversation as resolved.
}
}
return false
}
83 changes: 83 additions & 0 deletions internal/handlers/handlers_root_redirect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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("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)
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())
})
}
Loading