Repository navigation
fix(saml): validate SAML responses against the Web Browser SSO profile - #25
Conversation
- Treat an unrecognized signatureValidationType as responseandassertion. - Require the selected signatures and verify any signature that is present; verify.signature.credentials/profile can no longer be turned off. - Require exactly one assertion in the configured form. - Add SamlResponseValidator: Destination, Response/Assertion Issuer, Conditions window, AudienceRestriction, bearer SubjectConfirmationData (Recipient, NotOnOrAfter, InResponseTo), AuthnStatement, assertion replay. - Bind responses to the AuthnRequest that started them with a short-lived per-request cookie; unsolicited responses need allow.unsolicited.responses. - Document the new properties and the validation steps in the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Accept only RSA or ECDSA signatures with SHA-256/384/512 and SHA-256/384/512 reference digests, read from the SignedInfo that is verified. RSA-SHA1 and SHA-1 digests are accepted only with the new allow.sha1.signatures property, with a warning; HMAC, MD5, DSA and unknown algorithms are always rejected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
swicken
left a comment
There was a problem hiding this comment.
Thanks, this is a solid piece of work. The profile checks follow the spec closely, comparing Destination and Recipient against the configured ACS URL rather than the request URL is the right call behind a TLS-terminating proxy, and the cookie-based request binding is a good fit given that JSESSIONID is SameSite=Lax. Making unrecognized validation types fail closed and removing the ways to switch signature checks off is the direction we want. I ran ./mvnw test on the branch: 66 tests, all passing.
Two things need to change before this can merge, plus some smaller points.
Needs changes
1. The replay cache has to be shared across the cluster. SamlResponseValidator keeps used assertion IDs in a MemoryStorageService on each node. Most of our deployments run two or more nodes behind a load balancer, so an assertion that one node has already accepted can be accepted again by another node, and by any node after a restart, until it expires. The request-state cookie doesn't cover this, because its name and value are not secret. With allow.unsolicited.responses=true, the replay cache is the only protection against replay at all.
A suggestion for the store: a small table that the bundle creates itself, accessed through DotConnect. Use the assertion ID as the primary key, store an expiry in epoch millis, write with INSERT ... ON CONFLICT (assertion_id) DO NOTHING (an affected-row count of 0 means a replay), and delete expired rows as part of the same check. Why this option:
com.dotmarketing.common.dbis exported to the system OSGi framework on main and on the 25.07 LTS, and core is Postgres-only on both, so the fix stays bundle-only.- The Lettuce Redis client is not exported to that framework.
DotCacheAdministratoronly sends invalidations to other nodes, never the stored values, so it can't be used either.
2. No test covers a signed Response. Every signed fixture in TestSamlSignatureVerification and TestHttpPostAssertionResolverHandler signs the assertion only. Shibboleth signs the Response and encrypts the assertion by default, and some Entra ID setups sign only the Response. For those IdPs, the path through verifySignature for the Response is untested: the setIdAttribute on the Response DOM, the algorithm check on the Response's SignedInfo, and the profile and credential checks. Please add end-to-end happy paths through TestHttpPostAssertionResolverHandler for a Response-signed IdP, ideally one with an unencrypted assertion and one with an encrypted assertion.
Smaller points
3. The OpenSAML rationale is slightly off. The Javadoc of SamlResponseValidator and the PR description say SAML20AssertionValidator isn't available in 3.3.x. It is: it's in opensaml-saml-api-3.3.1.jar. What 3.3.1 lacks are the issuer and InResponseTo parameters (VALID_ISSUERS, SC_VALID_IN_RESPONSE_TO, both added in 4.0). It also treats a missing Conditions, AudienceRestriction, Recipient or NotOnOrAfter as valid, and it resolves SubjectConfirmationData@Address through DNS. Those are good reasons to implement the checks explicitly, so only the explanation needs correcting.
4. Cookies per AuthnRequest. Each login attempt adds its own dotsaml_req_<id> cookie for up to 15 minutes. The ACS returns an error rather than redirecting, so this only grows with repeated manual retries. Still, a bound would be cheap: for example, one cookie per IdP configuration holding the latest request ID, or a cap on how many are kept. Separately, the cookie already meets the __Host- prefix rules (Secure, Path=/, no Domain). Adding the prefix would stop a sibling subdomain from planting one.
5. Release notes. Beyond the four behaviour changes already listed in the description, two need prominent mention:
- IdP-initiated login stops working by default. Core added routing specifically for IdP-initiated logins in
DotSamlResourcethis July (8e943ee0a1), so some sites rely on it, and support will needallow.unsolicited.responsesdocumented. - Destination and Recipient must exactly match the ACS URL derived from the Service Provider Endpoint Hostname. Sites whose IdP posts to a different hostname will be rejected.
The log messages are clear enough that a short mapping from message to fix would make those support tickets quick.
6. Release order with dotCMS/core#37865. That PR rewrites a stored none to responseandassertion. With the bundle core ships today, which requires an exact signature match, every IdP that doesn't sign both the Response and the Assertion would be rejected. So this bundle needs to ship before that core change, or together with it.
7. Nit: the bearer check accepts a SubjectConfirmationData NotBefore as long as it isn't in the future, although Profiles §4.1.4.2 says a bearer confirmation must not have one. Being lenient seems fine for compatibility, but a short comment would make that look deliberate rather than an oversight.
|
Correction to point 6: the release order with dotCMS/core#37865 doesn't matter. With the current bundle, |
- Replay protection shared across nodes and restarts: used assertion IDs are recorded in a dotsaml_assertion_replay table the bundle creates, through its own auto-commit connection (INSERT ... ON CONFLICT DO NOTHING). A database error rejects the response. - Request-binding cookie: one __Host- cookie per IdP configuration holding the five most recent request IDs, each with its issue time. - Keep the Response DOM when logging an unsigned plain assertion: re-marshalling the assertion released its parent's DOM, so a Response-signed IdP with an unencrypted assertion was rejected as unsigned. - Stop logging the SP private key when decrypting an assertion. - Correct the OpenSAML 3.3.1 rationale; comment the lenient bearer NotBefore. - Tests: Response-signed IdPs with plain and encrypted assertions, an altered signed Response, the replay store, the cookie. README: troubleshooting table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, Scott. Addressed in bfb829d:
I also removed a log line in assertion decryption that printed the SP credential's private key. 85 tests pass. |
swicken
left a comment
There was a problem hiding this comment.
Thanks, Mehdi, bfb829d addresses everything from the first review. I re-checked it:
- All 85 tests pass locally.
- I ran the replay-table statements against Postgres 16. The first use inserts a row, a replay inserts none, expired rows are deleted and unexpired ones kept, and repeating the table creation is harmless.
- I reproduced the
SamlUtilsbug: with the oldtoXMLObjectString, three of the new handler tests fail, including the Response-signed IdP with a plain assertion. Good catch; that fix is worth a line in the release notes.
One more issue came out of a final pass, and it needs either a fix or an explicit decision before release.
A login that starts on a different host than the configured endpoint host is rejected. AuthnRequestStateCookie.remember sets the cookie on the response to whatever host the user started the login on. The AuthnRequest, though, always names https://<Service Provider Endpoint Hostname>/dotsaml/login/<id> as the ACS (see EndpointServiceImpl.getAssertionConsumerEndpoint). When the two hosts differ, the browser doesn't send the cookie to the ACS, and consume rejects the response as "not started from this browser". The __Host- prefix isn't the cause; the first version's cookie had no Domain attribute either, so it was also tied to one host.
This affects users who browse a site alias, and sites that use the System Host's SAML configuration while being served under a different hostname. Those logins work today and end on the endpoint host. I traced this through core (SamlWebInterceptor, DotSamlResource.doLogin) and the bundle, and found nothing that moves the user to the endpoint host before the AuthnRequest is built. I haven't reproduced it on a live instance.
A possible fix: when the request host differs from the endpoint host, redirect first to https://<endpoint host>/dotsaml/login/<id> (DotSamlResource.doLogin), passing the return path along. The AuthnRequest and its cookie are then issued by the host that receives the response. Users already end up on the endpoint host after login today, so they shouldn't notice any difference. If you'd rather not change the flow now, it needs a release note and an entry in the support table. A handler or validator test with different start and ACS hosts would pin it down either way.
Optional, not blocking: the static settable clock in AuthnRequestStateCookie could be passed in rather than held as mutable static state. Also, a pre-existing issue: posting a SAML message that isn't a Response to the ACS throws a ClassCastException in HttpPostAssertionResolverHandlerImpl, which shows up as a 500 rather than a clean rejection.
- A login that starts on a host other than the Service Provider Endpoint Hostname is first redirected to the assertion consumer URL on the endpoint host, where a GET starts it again, so the request-binding cookie is set on the host the IdP posts back to. A marker parameter prevents a second redirect; hosts are compared without ports. - A SAML message that isn't a Response posted to the ACS is rejected cleanly instead of failing with a ClassCastException. - AuthnRequestStateCookie takes its clock as a parameter instead of mutable static state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, Scott. Went with your suggestion, in bfb5d40:
92 tests pass. |
Part of dotCMS/private-issues#725. Companion core PR: dotCMS/core#37865.
What
Brings SAML Response consumption in line with the SAML 2.0 Web Browser SSO profile (Profiles §4.1.4.2–4.1.4.3, Core §2.5.1, Bindings §3.5.5.2).
Changes
Signatures (
CredentialServiceImpl,SamlCoreServiceImpl)signatureValidationType(blank,none, a typo) is treated asresponseandassertion.verify.signature.credentialsandverify.signature.profilecan no longer be set tofalse; the value is ignored and logged.allow.sha1.signatures=true(logged as a warning); HMAC, MD5, DSA and unknown algorithms are always rejected.Profile checks (
SamlResponseValidator, new; runs after the signature checks inHttpPostAssertionResolverHandlerImpl)Destinationequals the assertion consumer URL (required when the Response is signed). Compared with the configured ACS URL, plus its/api/v1form, rather than the servlet request URL, which differs behind a TLS-terminating proxy and after the/dotsaml/loginrewrite.Issuer(when present) and AssertionIssuerequal the IdP entityID from the metadata.ConditionsNotBefore/NotOnOrAfter, and everyAudienceRestrictionincludes the SP Issuer ID.SubjectConfirmationwhoseSubjectConfirmationDatahas an unexpiredNotOnOrAfter, aRecipientequal to the ACS URL, and anInResponseToequal to the Response's.AuthnStatement;SessionNotOnOrAfterhonored.DatabaseAssertionReplayStore, new): the bundle creates adotsaml_assertion_replaytable in the dotCMS database (viacom.dotmarketing.db, exported to the system OSGi framework on main and 25.07 LTS) and, on its own auto-commit connection, deletes expired rows and inserts the assertion key withON CONFLICT DO NOTHING. One inserted row means first use. A database error rejects the response.Request binding (
AuthnRequestStateCookie, new)__Host-dotsaml_req_<IdP configuration>cookie (HttpOnly; Secure; SameSite=None; Path=/) holding the five most recent AuthnRequest IDs, each with its issue time. A Response'sInResponseTomust match an unexpired one, which is then removed. A cookie is used rather than the HTTP session because the session cookie isSameSite=Laxand is not sent on the IdP's cross-site POST.EndpointHostRedirect, fromOpenSamlAuthenticationServiceImpl.authentication). The cookie is then set on the host the IdP posts back to. Users already end up on the endpoint host after login today. A marker parameter prevents a second redirect, and hosts are compared without ports.InResponseTo(IdP-initiated login) are rejected unlessallow.unsolicited.responses=true.Other
SamlUtils.toXMLObjectStringserializes an existing DOM instead of marshalling again. Logging an unsigned, unencrypted assertion re-marshalled it, which released the Response's DOM; OpenSAML readsisSigned()from the DOM, so a Response-signed IdP with a plain assertion was rejected as unsigned (with "does not match" before this PR).ClassCastException(a 500).validateDestinationAndLifetimerenamed tovalidateMessageLifetime(it only ever checked the lifetime).verifyStatusno longer throws aNullPointerExceptionfor a missing status or a status without a sub-code.clock.skew/message.life.timeunits corrected to milliseconds.New optional properties
allow.unsolicited.responses(defaultfalse).authn.request.max.age, seconds an AuthnRequest stays valid (default 900, clamped to 60–3600).allow.sha1.signatures(defaultfalse).Behaviour changes (release notes)
DotSamlResourcesince July (8e943ee0a1), so some sites rely on it. They needallow.unsolicited.responses=true.https://<hostname>/dotsaml/login/<site id>, or its/api/v1form). Sites whose IdP posts to another hostname are rejected.nonerequire a signed response and assertion.verify.signature.credentials=false/verify.signature.profile=falseno longer have any effect.allow.sha1.signatures=true.Support can map each rejection's log message to its fix with the README's troubleshooting table.
Notes
SAML20AssertionValidator, but not the parameters for valid issuers or the expected InResponseTo (VALID_ISSUERS,SC_VALID_IN_RESPONSE_TO, both 4.0). It also treats a missing Conditions, AudienceRestriction, Recipient or NotOnOrAfter as valid, and resolvesSubjectConfirmationData@Addressthrough DNS. So the checks are implemented explicitly.SubjectConfirmationDataNotBefore (Profiles §4.1.4.2 says there shouldn't be one) and only checks that it isn't in the future, like OpenSAML.com.dotcms.samlbundlebump in core.Testing
./mvnw test: 92 tests pass, 71 of them new.TestSamlResponseValidator(27): each profile check, clock skew, URL normalization, replay, request binding, unsolicited handling.TestSamlSignatureVerification(15): fail-closed validation type, signatures that can't be disabled, required vs. present signatures with real RSA signatures, algorithm allow-list (SHA-1 signature and digest, SHA-1 opt-in, stronger RSA, HMAC), assertion count.TestHttpPostAssertionResolverHandler(10, including a posted message that isn't a Response): end to end through HTTP-POST decoding: assertion-signed, Response-signed with a plain and with an encrypted assertion, both signed (under each Validation Type), Response-only signature when the assertion must be signed, an altered signed Response, the unsigned response under "none".TestDatabaseAssertionReplayStore(6),TestAuthnRequestStateCookie(7),TestEndpointHostRedirect(5) andTestLoginStartHost(1, a login started on an alias is redirected before any AuthnRequest or cookie). The replay-store SQL was also run against Postgres 16 (first insert 1 row, replay 0 rows, expired rows deleted).Not yet tested against a live IdP. QA should cover Entra ID and Okta, SP- and IdP-initiated.
🤖 Generated with Claude Code