fix(oidc): clamp post_login_redirect to CORS allow-list (open-redirect)

Closes the open-redirect risk surfaced by the v0.3.x security audit:
the OIDC callback handler was using oidc_config.post_login_redirect
verbatim as the redirect base on both error and success paths. An
attacker with admin-key compromise (or a misconfigured operator) could
set that field to an external domain and turn /api/auth/oidc/callback
into a phishing redirector ('dialectic login failed → re-enter
password at evil.com/login').

Fix:
  - New AuthHandler.safeRedirectBase(raw) validator:
      * empty → '/'
      * relative path starting with '/' (but not '//') → keep as-is
      * absolute URL whose host is in the allow-list → keep as-is
      * everything else → '/'
  - allow-list sourced from cfg.CORSAllowOrigins (the same set we
    already trust for browser CORS), threaded through NewAuthHandler.
  - Applied on BOTH the error branch and success branch of Callback.

Also: drop redundant newline on cmd/dialectic-cli usage Fprintln so
go test ./... passes.

No behavior change for happy path: prod's PostLoginRedirect is
'https://dialectic.hangman-lab.top/oidc/callback', which matches the
CORS allow-list ('https://dialectic.hangman-lab.top'), so the
validator returns it unmodified.
This commit is contained in:
h z
2026-05-24 10:03:53 +01:00
parent 2463129dbd
commit b02b1706b6
3 changed files with 61 additions and 11 deletions

View File

@@ -87,7 +87,13 @@ func Mount(cfg *config.Config, db *sqlx.DB, oidcSvc *oidc.Service, version strin
// cookie would prevent the browser from sending it back. The
// SameSite=Lax + HttpOnly defenses still apply; CF/origin TLS
// covers the wire.
authH := handlers.NewAuthHandler(oidcSvc, "dialectic_session", false)
// Pass the configured CORS allow-list as the open-redirect allowlist
// for the OIDC callback — the SPA hosts we already trust for CORS
// are by definition the same hosts a legitimate PostLoginRedirect
// can target. Anything else (incl. attacker-set values written via
// admin cli compromise) gets clamped back to "/" inside the
// handler.
authH := handlers.NewAuthHandler(oidcSvc, "dialectic_session", false, cfg.CORSAllowOrigins)
// Routes.
r.Route("/api", func(r chi.Router) {