Stop refusing sign-in over a scheme other than the public URL's
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Successful in 1m25s

The auth routes no longer return 400 when the resolved request scheme
differs from MEALQUEST_PUBLIC_URL's. If Docker's published-port path
presents its bridge gateway instead of Traefik as the direct peer, the
forwarded headers are rightly ignored, but sign-in must still work: the
OIDC redirect URI and the Secure cookies are fixed by MEALQUEST_PUBLIC_URL,
not by the request. internal/auth is back to its state before the
trusted-proxy change; client IP and scheme resolution stays in the router.

Add TestLoginThroughRouter: it wires the real auth flow and session
manager through server.NewRouter, trusting Traefik only, and checks that
a login from Traefik and from an untrusted peer both redirect with the
redirect URI from MEALQUEST_PUBLIC_URL and set a Secure login cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 11:01:25 -04:00
co-authored by Claude Opus 5.5
parent 7e49b72ad4
commit 17d5152247
5 changed files with 152 additions and 185 deletions
+1 -33
View File
@@ -19,7 +19,6 @@ import (
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
)
@@ -84,10 +83,7 @@ type Auth struct {
// endSessionURL is the provider's end_session_endpoint; logout ends
// there so the provider's own session ends too.
endSessionURL string
// publicScheme is the public URL's scheme; requests to the auth routes
// must have arrived over it.
publicScheme string
logger *slog.Logger
logger *slog.Logger
}
// New discovers the provider at cfg.Issuer and returns the login flow. It
@@ -159,7 +155,6 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
publicScheme: cfg.PublicURL.Scheme,
logger: logger,
}, nil
}
@@ -198,39 +193,12 @@ func checkRedirect(req *http.Request, via []*http.Request) error {
}
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
// The routes need the request's resolved scheme in the context (see package
// forwarded) and refuse requests that did not arrive over the public URL's
// scheme.
func (a *Auth) Mount(r chi.Router) {
r = r.With(a.requirePublicScheme)
r.Get(LoginPath, a.login)
r.Get(CallbackPath, a.callback)
r.Post(LogoutPath, a.logout)
}
// requirePublicScheme refuses a request whose resolved scheme differs from
// the public URL's. The login and session cookies are Secure, and the
// redirect URI is always built from the public URL, so a sign-in reaching the
// service over another scheme cannot work: behind a TLS-terminating proxy
// this means the proxy's X-Forwarded-Proto was not honoured, because the
// proxy is not a trusted peer.
func (a *Auth) requirePublicScheme(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
scheme, ok := forwarded.Scheme(r.Context())
switch {
case !ok:
a.logger.ErrorContext(r.Context(), "auth route reached without a resolved request scheme")
http.Error(w, "internal error", http.StatusInternalServerError)
case scheme != a.publicScheme:
a.logger.WarnContext(r.Context(), "auth request scheme differs from the public URL; is the proxy a trusted peer?",
"scheme", scheme, "public_scheme", a.publicScheme)
http.Error(w, "sign-in must use the service's public URL", http.StatusBadRequest)
default:
next.ServeHTTP(w, r)
}
})
}
// EndSessionURL is the provider's end_session_endpoint.
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
+4 -148
View File
@@ -17,7 +17,6 @@ import (
"math/big"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"slices"
"strings"
@@ -27,8 +26,6 @@ import (
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
)
const (
@@ -350,32 +347,11 @@ func newHarness(t *testing.T) *harness {
if err != nil {
t.Fatal(err)
}
r := publicRouter(a, testPublicURL.Scheme)
r := chi.NewRouter()
a.Mount(r)
return &harness{provider: p, sessions: sessions, router: r, logs: logs}
}
// publicRouter mounts a's routes behind the request-origin resolution the
// server installs, with every request taken to have arrived over scheme.
// Requests with noOrigin set skip the resolution.
func publicRouter(a *Auth, scheme string) http.Handler {
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Header.Get(noOrigin) == "" {
o := forwarded.Origin{ClientIP: netip.MustParseAddr("192.0.2.1"), Scheme: scheme}
req = req.WithContext(forwarded.NewContext(req.Context(), o))
}
next.ServeHTTP(w, req)
})
})
a.Mount(r)
return r
}
// noOrigin is a test-only request header telling publicRouter to leave the
// request's origin unresolved.
const noOrigin = "X-Test-No-Origin"
func (h *harness) get(target string, cookies ...*http.Cookie) *http.Response {
req := httptest.NewRequest(http.MethodGet, target, nil)
for _, c := range cookies {
@@ -457,127 +433,6 @@ func TestLoginRedirect(t *testing.T) {
}
}
func TestAuthRoutesRequirePublicScheme(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
logs := &syncBuffer{}
a, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.NewTextHandler(logs, nil)))
if err != nil {
t.Fatal(err)
}
// The proxy's X-Forwarded-Proto was not honoured: the request looks like
// plain http while the public URL is https.
plain := publicRouter(a, "http")
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, LoginPath, nil),
httptest.NewRequest(http.MethodGet, CallbackPath+"?code=c&state=s", nil),
httptest.NewRequest(http.MethodPost, LogoutPath, nil),
} {
rec := httptest.NewRecorder()
plain.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s %s over http = %d, want 400", req.Method, req.URL.Path, rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "" {
t.Errorf("%s %s over http redirected to %q", req.Method, req.URL.Path, loc)
}
if c := rec.Result().Cookies(); len(c) != 0 {
t.Errorf("%s %s over http set cookies %v", req.Method, req.URL.Path, c)
}
}
if !strings.Contains(logs.String(), "trusted peer") {
t.Errorf("logs = %q, want the scheme mismatch explained", logs.String())
}
bare := chi.NewRouter()
a.Mount(bare)
rec := httptest.NewRecorder()
bare.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, LoginPath, nil))
if rec.Code != http.StatusInternalServerError {
t.Errorf("login without a resolved origin = %d, want 500", rec.Code)
}
}
// TestLoginBehindTraefik resolves each request with forwarded.Resolve, as
// the server's middleware does, trusting Traefik only.
func TestLoginBehindTraefik(t *testing.T) {
t.Parallel()
p := newFakeProvider(t)
a, err := New(context.Background(), Config{
Issuer: p.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
PublicURL: testPublicURL,
SecretKey: []byte("server-secret-key"),
AllowedGroups: testAllowedGroups,
}, &memorySessions{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
trusted, err := forwarded.ParseTrusted("10.60.1.33")
if err != nil {
t.Fatal(err)
}
r := chi.NewRouter()
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
o, err := forwarded.Resolve(req, trusted)
if err != nil {
t.Errorf("Resolve: %v", err)
return
}
next.ServeHTTP(w, req.WithContext(forwarded.NewContext(req.Context(), o)))
})
})
a.Mount(r)
login := func(peer string) *http.Response {
// Traefik reaches the service over plain http on its internal
// address; the host headers must not leak into the redirect URI.
req := httptest.NewRequest(http.MethodGet, "http://mealquest:8080"+LoginPath, nil)
req.RemoteAddr = peer
req.Header.Set("X-Forwarded-For", "203.0.113.7")
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "evil.example.com")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec.Result()
}
resp := login("10.60.1.33:41000")
if resp.StatusCode != http.StatusFound {
t.Fatalf("login via Traefik status = %d, want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatal(err)
}
if got, want := loc.Query().Get("redirect_uri"), "https://meals.example.com/auth/callback"; got != want {
t.Errorf("redirect_uri = %q, want %q from the public URL", got, want)
}
cookies := resp.Cookies()
if len(cookies) != 1 || cookies[0].Name != loginCookieName || !cookies[0].Secure {
t.Errorf("login cookies = %v, want one Secure %s", cookies, loginCookieName)
}
// The same headers from an untrusted peer are ignored: the request is
// plain http, so sign-in is refused rather than setting a Secure cookie
// the browser would drop.
resp = login("198.51.100.9:5000")
if resp.StatusCode != http.StatusBadRequest || len(resp.Cookies()) != 0 {
t.Errorf("login spoofing X-Forwarded-Proto = %d with cookies %v, want 400 and none",
resp.StatusCode, resp.Cookies())
}
}
func TestLoginStateIsFreshEachTime(t *testing.T) {
t.Parallel()
h := newHarness(t)
@@ -1049,7 +904,8 @@ func TestCallbackRefusesTokenRedirectToHTTP(t *testing.T) {
}
// Trust the test server's certificate; the redirect policy stays.
a.client.Transport = tlsToken.Client().Transport
r := publicRouter(a, testPublicURL.Scheme)
r := chi.NewRouter()
a.Mount(r)
h := &harness{provider: p, sessions: sessions, router: r, logs: logs}
l := h.login(t, LoginPath)
+4 -1
View File
@@ -13,6 +13,8 @@ import (
"testing"
"time"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -70,7 +72,8 @@ func newSessionHarness(t *testing.T) *sessionHarness {
if err != nil {
t.Fatal(err)
}
r := publicRouter(a, testPublicURL.Scheme)
r := chi.NewRouter()
a.Mount(r)
h.harness = &harness{provider: p, router: r, logs: logs}
h.auth = a
return h