Honour forwarded headers only from MEALQUEST_TRUSTED_PROXY
CI / ci (pull_request) Successful in 1m26s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high, 1 medium)
CI / ci (push) Successful in 1m25s

Add internal/forwarded: parse the trusted proxy list (comma-separated IPs
and CIDRs, validated at load), resolve each request's client IP and scheme,
and expose them through context helpers. X-Forwarded-For and
X-Forwarded-Proto are honoured only when the TCP peer is trusted; the client
is the rightmost X-Forwarded-For address that is not a trusted proxy.
Malformed forwarded headers from a trusted peer get 400.

The router resolves every request first; a slog handler adds client_ip to
request-scoped log lines. The auth routes refuse requests whose resolved
scheme differs from MEALQUEST_PUBLIC_URL's, so a proxy that is not trusted
fails loudly instead of breaking Secure cookies. The OIDC redirect URI is
still built only from MEALQUEST_PUBLIC_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:57:23 -04:00
co-authored by Claude Opus 5.5
parent 36b975ff5b
commit 7e49b72ad4
14 changed files with 943 additions and 15 deletions
+33 -1
View File
@@ -19,6 +19,7 @@ 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"
)
@@ -83,7 +84,10 @@ type Auth struct {
// endSessionURL is the provider's end_session_endpoint; logout ends
// there so the provider's own session ends too.
endSessionURL string
logger *slog.Logger
// publicScheme is the public URL's scheme; requests to the auth routes
// must have arrived over it.
publicScheme string
logger *slog.Logger
}
// New discovers the provider at cfg.Issuer and returns the login flow. It
@@ -155,6 +159,7 @@ 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
}
@@ -193,12 +198,39 @@ 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 }