fix(sso): refuse a SAML LogoutRequest that names another user - #3262
Open
marevol wants to merge 1 commit into
Open
fix(sso): refuse a SAML LogoutRequest that names another user#3262marevol wants to merge 1 commit into
marevol wants to merge 1 commit into
Conversation
/sso/logout is anonymous and, because SAML requires SameSite=none, is reachable cross-site with the victim's session cookie attached. With the shipped default saml.security.want_messages_signed=false, java-saml accepts a LogoutRequest carrying no signature, and every other check it makes is conditional on an attribute the sender simply omits: NotOnOrAfter, Destination, and even Issuer, which the protocol schema declares optional and whose absence therefore skips the entity ID comparison as well. The NameID is the one element java-saml insists on, and its value was never compared with anything, so ending an authenticated session took no knowledge of the deployment at all. Compare that NameID with the session user before letting java-saml invalidate the session. On a mismatch the session is kept and the IdP still receives an ordinary LogoutResponse, because an error would tell an unauthenticated sender whether it guessed a live session and would leave a confused-but-legitimate IdP unable to finish its own logout. The comparison is deliberately more forgiving than equals. Both sides are read from the text content of two different XML documents and java-saml trims neither unless saml.parsing.trim_name_ids is on, which Fess leaves off, so an IdP that pretty-prints one message and not the other would otherwise look like a different user. It also ignores case, since an IdP that normalises an email or UPN differently between its assertion and its LogoutRequest is a real deployment. The risk is not symmetric: a false match only leaves the previous behaviour in place, while a false mismatch silently breaks a working single logout. Anything short of a clear mismatch keeps the previous behaviour: nobody logged in, a user who did not come from SAML, or a NameID that cannot be read. The message is parsed with java-saml rather than by hand, so no XML parser is added in front of an unauthenticated sender, and the NameID is bounded and stripped of control characters before it reaches the log, the way SpnegoAuthenticator already treats a client-supplied realm. This narrows the exposure rather than closing it. A session whose NameID the sender already knows, and any session that did not come from SAML, are still ended by a crafted request; want_messages_signed=true remains the real fix. The class javadoc and the unsigned_logoutrequest_accepted comment are updated to say exactly that, since both previously asserted that the NameID is never compared.
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.
The problem
/sso/logoutis anonymous and, because SAML requirestomcat.sameSiteCookies=none,is reachable cross-site with the victim's session cookie attached. With the
shipped default
saml.security.want_messages_signed=false, java-saml accepts aLogoutRequest that carries no signature, and every other check it makes is
conditional on an attribute the sender simply omits:
LogoutRequest.java:479— only whengetWantMessagesSigned()NotOnOrAfter:452— guarded byhasAttributeDestination:462— guarded byhasAttributeIssuervssaml.idp.entityid:474—if (issuer != null && …), andgetIssuer()returnsnullwhen the element is absent (:905-917).saml-schema-protocol-2.0.xsd:31declares<element ref="saml:Issuer" minOccurs="0"/>, so omitting it still passes XML validationThe NameID is the one element java-saml insists on (
:694-696throwsNO_NAMEID),and its value was never compared with anything. So ending an authenticated
session required no knowledge of the deployment at all — not even the IdP entity
ID.
Auth.processSLO(false, …)then invalidates the session atAuth.java:1242.Impact is a forced logout (denial of service), not account takeover.
The change
getLogoutResponse()now compares the LogoutRequest's NameID with the sessionuser's before letting java-saml invalidate the session. On a mismatch the
session is kept and the IdP still gets an ordinary LogoutResponse — an error
would tell an unauthenticated sender whether it guessed a live session, and
would leave a confused-but-legitimate IdP unable to finish its own logout.
SamlUser.getName()is the NameID: it is whatlogout(FessUserBean)alreadypasses as the
nameIdof the LogoutRequest Fess sends.The
SAMLResponsebranch is untouched.Why the comparison is not
equalsThe risk is asymmetric — a false match only restores the previous behaviour,
while a false mismatch silently breaks a working single logout.
different XML documents, and java-saml trims neither unless
saml.parsing.trim_name_idsis on, which Fess leaves off. An IdP thatpretty-prints its LogoutRequest but not its assertion would otherwise look
like a different user on every logout.
between its assertion and its LogoutRequest is a real deployment. It costs
nothing: a sender who does not know the NameID fails at any case, and one who
does gains nothing from changing it.
Fail-open by design
Nothing short of a clear mismatch changes behaviour: nobody logged in, a user
who did not come from SAML, a malformed message, an
EncryptedIDwith no SP keyto open it. All of them mean "cannot tell", which keeps today's behaviour.
The message is parsed through
new LogoutRequest(settings, ServletUtils.makeHttpRequest(request))with the same argument list
LogoutRequest.isValid()uses, not by hand — handparsing would put an XML parser, and therefore an XXE surface, in front of an
unauthenticated sender. Constructing a
LogoutRequesttouches no replay cache(only
isValid()registers an ID), so parsing twice does not make java-samlreject its own copy.
The NameID is bounded to 64 characters and stripped of control characters before
it reaches the log, the way
SpnegoAuthenticator.sanitizeForLogalready treats aclient-supplied realm — it is written before anything authenticated the
message, and it is XML text content, so it can carry a newline.
What this does NOT fix
This narrows the exposure, it does not close it. Still ended by a crafted
request:
emailAddressformat, that may just be the victim's email address)NameID to compare
saml.security.want_messages_signed=trueremains the real fix. The class javadocand the
unsigned_logoutrequest_acceptedcomment are updated to say exactlythis, since both previously asserted that the NameID is never compared.
Verification
mvn -o clean test -Dtest=SamlAuthenticatorTest→Tests run: 45, Failures: 0, Errors: 0, Skipped: 0(was 36)mvn -o test -Dtest='*Sso*,*Saml*'→Tests run: 167, Failures: 0, Errors: 0, Skipped: 0mvn -o javadoc:jar→ BUILD SUCCESS (the one remaining warning is the pre-existing, unrelatedChunkBoundaryFinder.java:882)mvn -o formatter:format license:format→ no further changes12 mutations were applied to the production code and each was confirmed to fail a
test before being restored: restoring
processSLO(false, …), dropping theSAMLRequestguard, dropping.trim(),equalsIgnoreCase→equals, droppingthe
instanceof SamlUsercheck, removing the exception swallowing, dropping theblank-NameID guards, removing the WARN, inverting the comparison, forcing the
NameID reader to null, and making
sanitizeForLogreturn its input unchanged.None passed.
Worth knowing before merge
want_messages_signed=truethe message is authenticated, yet a NameID mismatchstill keeps the session. That is deliberate — honouring it would log out the
wrong user — but it means a correctly-secured deployment is also exposed to the
false-mismatch failure mode. There is no kill switch; a mismatch is at least
no longer silent, since the WARN names both values.
getSavedUserBean()is not covered by a test. Inthe unit container
FessLoginAssistresolves as a component def but fails toauto-inject its
@Resource UserBhv(no user index), so the tests override aprotectedseam — the same idiomMeHandlerTestuses.ViewHelperandMeHandlerrely on the same call in production. One manual SLO round tripagainst a real IdP before release would be worth it.
Related