This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cxf.git


The following commit(s) were added to refs/heads/main by this push:
     new 75ddd3a9ee2 Check SAML SSO assertions for a replay once, not per 
Subject Confirmation (#3699)
75ddd3a9ee2 is described below

commit 75ddd3a9ee24c18d3541255da6450ddec9e7f225
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Tue Nov 10 13:30:28 2026 +0000

    Check SAML SSO assertions for a replay once, not per Subject Confirmation 
(#3699)
---
 .../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) {

Reply via email to