This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch 4.1.x-fixes
in repository https://gitbox.apache.org/repos/asf/cxf.git
The following commit(s) were added to refs/heads/4.1.x-fixes by this push:
new 67960ed7fcd Check SAML SSO assertions for a replay once, not per
Subject Confirmation (#3699)
67960ed7fcd is described below
commit 67960ed7fcdcd17ce07af93c7fa3a3d7b9ac8d6d
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)
(cherry picked from commit 75ddd3a9ee24c18d3541255da6450ddec9e7f225)
---
.../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) {