fix(sso): stop a SAML LogoutResponse from ending a session it never belonged to - #3324
Merged
Merged
Conversation
…elonged to /sso/logout is anonymous and, because SAML requires SameSite=none, reachable cross-site with the victim's session cookie attached. A LogoutResponse arriving there was passed to Auth#processSLO with no request ID to bind it to, because the SP has none to give: LogoutAction asks the SSO manager for the redirect URL and then calls logout(), which invalidates the session that would have held it. java-saml therefore skips the InResponseTo comparison, and with the shipped saml.security.want_messages_signed=false every remaining check is conditional on an attribute the sender may simply omit -- Issuer, Destination, and the InResponseTo attribute itself. A LogoutResponse carrying nothing but a Success status was consequently enough to invalidate whatever session it was pointed at. No signature, no guess at a NameID, and nothing written to the log. It ended a SAML login, a local one, and a login still in flight, whose pending AuthnRequest ID went with the session and left the user unable to log in for as long as the request kept being made. Nothing is lost by keeping the session: a legitimate LogoutResponse answers a LogoutRequest this SP sent, and by the time one can arrive the local login has already been ended, so the session it lands on is a fresh one and invalidating it ends nothing that was still running. This is the same exposure the NameID comparison closes for a LogoutRequest, where that comparison costs the sender a guess; a LogoutResponse carries no NameID, and needs none, because there is nothing left for it to end. A LogoutResponse that reaches a session which is still logged in is reported, since that is a message answering a logout nobody started. The line is bounded, carries no stack trace and echoes nothing the sender supplied, for the reason the rest of this endpoint gives: an anonymous endpoint must not let an unauthenticated client fill the log. A request carrying both parameters stays on the LogoutRequest branch, which the NameID comparison already guards.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
/sso/logoutaccepted an unsigned, unsolicited SAMLLogoutResponseand endedwhatever session the request carried. This keeps the session for that kind of
message, and reports one bounded line when the message reached a login that was
still live.
Why
/sso/logoutis anonymous, and because SAML requiresSameSite=noneon thesession cookie it is reachable cross-site with the victim's cookie attached.
Auth#processSLOwas givennullas the request ID, so java-saml skipped theInResponseTocomparison. With the shippedsaml.security.want_messages_signed=falseevery remaining check is conditional on an attribute the sender may simply omit:
Issuer,Destination, and theInResponseToattribute itself. ALogoutResponsecontaining nothing but aSuccessstatus therefore passedvalidation, and
processSLOinvalidated the session.The SP has no request ID to give:
LogoutActionasks the SSO manager for theredirect URL and then calls
logout(), which invalidates the session that wouldhave held it. So the binding cannot be restored by storing the ID there.
Measured on a Keycloak-backed setup, against
master:LogoutResponse0log lines1WARNLogoutRequestnaming the session's own userLogoutRequestnaming another userThe impact is denial of service rather than escalation: an attacker ends
sessions and, while the request keeps being made, prevents logins from
completing.
saml.security.want_messages_signed=truealready prevented it,which is why the start-up banner recommends it — but the shipped default did not.
How
LogoutResponseno longer ends the local session. Nothing is lost by that:a legitimate one answers a
LogoutRequestthis SP sent, and by the time it canarrive
LogoutActionhas already ended the login, so the session it lands onis a fresh one. This is the same exposure the NameID comparison closes for a
LogoutRequest(fix(sso): refuse a SAML LogoutRequest that names another user #3262); aLogoutResponsecarries no NameID, and needs none,because there is nothing left for it to end.
LogoutResponsethat reaches a session which is still logged in is reported.That never happens on the path a logout actually takes, so the legitimate round
trip stays silent. The line is bounded, carries no stack trace and echoes
nothing the sender supplied — an anonymous endpoint must not let an
unauthenticated client fill the log.
LogoutRequestbranch, decidedthe same way round as
Auth#processSLOreads them.Tests
SamlAuthenticatorTest75 tests green (3 added), and 235 green across therelated SSO/exception suites.
Verified end to end against a Keycloak IdP: the forged message is refused and
reported, a login in flight completes afterwards, the SP-initiated SLO round trip
still ends the login without a warning, and both
LogoutRequestcases behave asbefore. Reverting the change turns six of those assertions red, so they are not
vacuous.