Validate X-Forwarded-Host before using it as a post-login redirect target

render_unauthorized built the post-login return URL directly from the
attacker-influenceable X-Forwarded-Host / X-Forwarded-Uri headers, stored it
in the session, and reflected it into the signin `rd`. After authentication
that URL is followed with allow_other_host, so a spoofed host was an open
redirect.

Now the forwarded URL is only honoured if it resolves to a known, active
forward-auth application (via validate_redirect_url); otherwise it falls back
to a validated `rd` or the IdP's base URL. Once render_unauthorized only ever
stores a validated value, the sessions_controller "supplement, don't replace"
behaviour is safe, so no change is needed there.

Two integration tests were asserting the old behaviour by reflecting
unregistered hosts (grafana.example.com, app.example.com); they now register
those domains as forward-auth apps so they exercise the real feature. Adds a
regression test that a spoofed X-Forwarded-Host is neither stored nor
reflected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-06-11 08:00:12 +10:00
parent 8a095e4939
commit 96a657e349
2 changed files with 37 additions and 5 deletions

View File

@@ -196,11 +196,14 @@ module Api
original_host = request.headers["X-Forwarded-Host"]
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
original_url = if original_host
"https://#{original_host}#{original_uri}"
else
redirect_url || base_url
end
# X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
# URL as a post-login redirect target if it resolves to a known, active
# forward-auth application. Otherwise this is an open redirect: a spoofed
# host would be stored and reflected into the signin `rd`, then followed
# (with allow_other_host) after the user authenticates. Fall back to a
# validated `rd` or, failing that, the IdP's own base URL.
forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present?
original_url = validate_redirect_url(forwarded_url) || redirect_url || base_url
session[:return_to_after_authenticating] = original_url