This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/saml-replay in repository https://gitbox.apache.org/repos/asf/cxf.git
commit b462471aa8e2437ead397fd8e4516defe48206ed Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Tue Nov 10 07:37:42 2026 +0000 Check SAML SSO assertions for a replay once, not per Subject Confirmation --- .../saml/sso/SAMLSSOResponseValidator.java | 65 ++++++++---- .../saml/sso/SAMLSSOResponseValidatorTest.java | 111 +++++++++++++++++++++ 2 files changed, 154 insertions(+), 22 deletions(-) diff --git a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java index 1830abf97d0..dfe9249cf10 100644 --- a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java +++ b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidator.java @@ -133,9 +133,11 @@ public class SAMLSSOResponseValidator { && !assertion.getAuthnStatements().isEmpty()) { org.opensaml.saml.saml2.core.Subject subject = assertion.getSubject(); org.opensaml.saml.saml2.core.SubjectConfirmation subjectConf = - validateAuthenticationSubject(subject, assertion.getID()); + validateAuthenticationSubject(subject); if (subjectConf != null) { validateAudienceRestrictionCondition(assertion.getConditions()); + // This must come after the other checks, so that only a valid Assertion is cached + checkReplay(subject, assertion.getID()); validAssertion = assertion; sessionNotOnOrAfter = null; @@ -256,7 +258,7 @@ public class SAMLSSOResponseValidator { * Validate the Subject (of an Authentication Statement). */ private org.opensaml.saml.saml2.core.SubjectConfirmation validateAuthenticationSubject( - org.opensaml.saml.saml2.core.Subject subject, String id + org.opensaml.saml.saml2.core.Subject subject ) throws WSSecurityException { if (subject.getSubjectConfirmations() == null) { return null; @@ -267,7 +269,7 @@ public class SAMLSSOResponseValidator { for (org.opensaml.saml.saml2.core.SubjectConfirmation subjectConf : subject.getSubjectConfirmations()) { if (SAML2Constants.CONF_BEARER.equals(subjectConf.getMethod())) { - validateSubjectConfirmation(subjectConf.getSubjectConfirmationData(), id); + validateSubjectConfirmation(subjectConf.getSubjectConfirmationData()); validSubjectConf = subjectConf; } } @@ -275,11 +277,48 @@ public class SAMLSSOResponseValidator { return validSubjectConf; } + /** + * Bearer Assertion IDs are cached until the Assertion expires, to detect replay attacks. This applies + * to every binding, not just POST, as a Response received via GET can be replayed too. An Assertion may + * contain more than one Bearer Subject Confirmation, so this is done once per Assertion, and the ID is + * cached until the latest NotOnOrAfter of its (already validated) Bearer Subject Confirmations. + */ + private void checkReplay(org.opensaml.saml.saml2.core.Subject subject, String id) + throws WSSecurityException { + if (replayCache == null) { + return; + } + + Instant expires = null; + for (org.opensaml.saml.saml2.core.SubjectConfirmation subjectConf + : subject.getSubjectConfirmations()) { + if (SAML2Constants.CONF_BEARER.equals(subjectConf.getMethod())) { + Instant notOnOrAfter = subjectConf.getSubjectConfirmationData().getNotOnOrAfter(); + if (expires == null || notOnOrAfter.isAfter(expires)) { + expires = notOnOrAfter; + } + } + } + + // The cache only holds the identifier for so long. An Assertion that is valid for longer + // could be replayed once its identifier has expired from the cache, so reject it + Duration maxTTL = replayCache.getMaxTTL(); + if (!allowAssertionsBeyondReplayCacheTTL && maxTTL != null + && expires.isAfter(Instant.now().plus(maxTTL))) { + LOG.warning("The Assertion " + id + " is valid for longer than the replay cache can hold it"); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + if (!replayCache.putIdIfAbsent(id, expires)) { + LOG.warning("Replay attack with token id: " + id); + throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); + } + } + /** * Validate a (Bearer) Subject Confirmation */ private void validateSubjectConfirmation( - org.opensaml.saml.saml2.core.SubjectConfirmationData subjectConfData, String id + org.opensaml.saml.saml2.core.SubjectConfirmationData subjectConfData ) throws WSSecurityException { if (subjectConfData == null) { LOG.warning("Subject Confirmation Data of a Bearer Subject Confirmation is null"); @@ -301,24 +340,6 @@ public class SAMLSSOResponseValidator { throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); } - // Need to keep bearer assertion IDs based on NotOnOrAfter to detect replay attacks. This - // applies to every binding, not just POST, as a Response received via GET can be replayed too - if (replayCache != null) { - Instant expires = subjectConfData.getNotOnOrAfter(); - // The cache only holds the identifier for so long. An Assertion that is valid for longer - // could be replayed once its identifier has expired from the cache, so reject it - Duration maxTTL = replayCache.getMaxTTL(); - if (!allowAssertionsBeyondReplayCacheTTL && maxTTL != null - && expires.isAfter(Instant.now().plus(maxTTL))) { - LOG.warning("The Assertion " + id + " is valid for longer than the replay cache can hold it"); - throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); - } - if (!replayCache.putIdIfAbsent(id, expires)) { - LOG.warning("Replay attack with token id: " + id); - throw new WSSecurityException(WSSecurityException.ErrorCode.FAILURE, "invalidSAMLsecurity"); - } - } - // Check address if (subjectConfData.getAddress() != null && clientAddress != null && !subjectConfData.getAddress().equals(clientAddress)) { diff --git a/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java b/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java index 398f5fa3c9b..e35b4357379 100644 --- a/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java +++ b/rt/rs/security/sso/saml/src/test/java/org/apache/cxf/rs/security/saml/sso/SAMLSSOResponseValidatorTest.java @@ -25,9 +25,12 @@ import java.security.PrivateKey; import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -50,6 +53,7 @@ import org.apache.wss4j.common.saml.SamlAssertionWrapper; import org.apache.wss4j.common.saml.bean.AudienceRestrictionBean; import org.apache.wss4j.common.saml.bean.ConditionsBean; import org.apache.wss4j.common.saml.bean.SubjectConfirmationDataBean; +import org.apache.wss4j.common.saml.builder.SAML2ComponentBuilder; import org.apache.wss4j.common.saml.builder.SAML2Constants; import org.apache.wss4j.common.util.Loader; import org.opensaml.saml.common.SignableSAMLObject; @@ -835,6 +839,93 @@ public class SAMLSSOResponseValidatorTest { return subjectConfirmationData; } + // An Assertion may contain more than one Bearer Subject Confirmation. It is checked for a replay once, + // rather than once per Subject Confirmation (which would reject it as a replay of itself), and its + // ID is cached until the latest NotOnOrAfter of them + @org.junit.Test + public void testAssertionWithTwoBearerSubjectConfirmations() throws Exception { + Instant notOnOrAfter = Instant.now().plus(Duration.ofMinutes(5)); + Instant laterNotOnOrAfter = notOnOrAfter.plus(Duration.ofMinutes(5)); + Response response = createResponse(createSubjectConfirmationData(notOnOrAfter), null, null, + createSubjectConfirmationData(laterNotOnOrAfter)); + + RecordingTokenReplayCache replayCache = new RecordingTokenReplayCache(); + SAMLSSOResponseValidator validator = createReplayValidator(replayCache); + + SSOValidatorResponse validateSamlResponse = validator.validateSamlResponse(response, true); + assertEquals(response.getID(), validateSamlResponse.getResponseId()); + // NotOnOrAfter is serialized with millisecond precision + assertEquals(laterNotOnOrAfter.truncatedTo(ChronoUnit.MILLIS), replayCache.lastExpiry); + + // The same response is replayed + try { + validator.validateSamlResponse(copyResponse(response), true); + fail("Expected failure on replayed response"); + } catch (WSSecurityException ex) { + // expected + } + } + + // The ID of an Assertion is cached until the latest NotOnOrAfter of its Bearer Subject Confirmations, + // so the Assertion is rejected if any of them is valid for longer than the replay cache can hold it + @org.junit.Test + public void testSecondBearerSubjectConfirmationBeyondReplayCacheTTL() throws Exception { + Response response = createResponse( + createSubjectConfirmationData(Instant.now().plus(Duration.ofMinutes(5))), null, null, + createSubjectConfirmationData(Instant.now().plus(Duration.ofHours(24)))); + + TokenReplayCache<String> replayCache = new EHCacheTokenReplayCache(); + try { + SAMLSSOResponseValidator validator = createReplayValidator(replayCache); + try { + validator.validateSamlResponse(response, true); + fail("Expected failure as the assertion is valid for longer than the replay cache holds it"); + } catch (WSSecurityException ex) { + // expected + } + } finally { + replayCache.close(); + } + } + + private static SubjectConfirmationDataBean createSubjectConfirmationData(Instant notOnOrAfter) { + SubjectConfirmationDataBean subjectConfirmationData = new SubjectConfirmationDataBean(); + subjectConfirmationData.setAddress("http://apache.org"); + subjectConfirmationData.setInResponseTo("12345"); + subjectConfirmationData.setNotAfter(notOnOrAfter); + subjectConfirmationData.setRecipient("http://recipient.apache.org"); + return subjectConfirmationData; + } + + /** + * A TokenReplayCache that records the expiry it was last asked to cache an ID for + */ + private static final class RecordingTokenReplayCache implements TokenReplayCache<String> { + private final Set<String> ids = ConcurrentHashMap.newKeySet(); + private Instant lastExpiry; + + @Override + public boolean contains(String id) { + return ids.contains(id); + } + + @Override + public void putId(String id) { + putId(id, null); + } + + @Override + public void putId(String id, Instant expiry) { + lastExpiry = expiry; + ids.add(id); + } + + @Override + public void close() { + ids.clear(); + } + } + private SAMLSSOResponseValidator createReplayValidator(TokenReplayCache<String> replayCache) { SAMLSSOResponseValidator validator = new SAMLSSOResponseValidator(); validator.setEnforceAssertionsSigned(false); @@ -911,6 +1002,19 @@ public class SAMLSSOResponseValidatorTest { SubjectConfirmationDataBean subjectConfirmationData, List<AudienceRestrictionBean> audienceRestrictions, String authnClassRef + ) throws Exception { + return createResponse(subjectConfirmationData, audienceRestrictions, authnClassRef, null); + } + + /** + * Create a Response, with an Assertion with a second Bearer Subject Confirmation if + * secondSubjectConfirmationData is not null + */ + private Response createResponse( + SubjectConfirmationDataBean subjectConfirmationData, + List<AudienceRestrictionBean> audienceRestrictions, + String authnClassRef, + SubjectConfirmationDataBean secondSubjectConfirmationData ) throws Exception { Document doc = DOMUtils.createDocument(); @@ -948,6 +1052,13 @@ public class SAMLSSOResponseValidatorTest { SAMLUtil.doSAMLCallback(callbackHandler, samlCallback); SamlAssertionWrapper assertion = new SamlAssertionWrapper(samlCallback); + if (secondSubjectConfirmationData != null) { + assertion.getSaml2().getSubject().getSubjectConfirmations().add( + SAML2ComponentBuilder.createSubjectConfirmation(SAML2Constants.CONF_BEARER, + SAML2ComponentBuilder.createSubjectConfirmationData(secondSubjectConfirmationData, null), + null)); + } + response.getAssertions().add(assertion.getSaml2()); if (authnClassRef != null) {
