This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch 2_4_x-fixes
in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
The following commit(s) were added to refs/heads/2_4_x-fixes by this push:
new 84ef7660e Fix StAX replaycache tests (#703)
84ef7660e is described below
commit 84ef7660ea46cccac45b7bc609d52078a8bbbba2
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Sep 17 09:37:22 2026 +0100
Fix StAX replaycache tests (#703)
---
.../WSSSignatureReferenceVerifyInputProcessor.java | 98 ++++++++++++++++++----
.../org/apache/wss4j/stax/test/ReplayTest.java | 93 ++++++++++++++++++++
2 files changed, 176 insertions(+), 15 deletions(-)
diff --git
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
index 5100103ca..4a9e0026e 100644
---
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
+++
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/WSSSignatureReferenceVerifyInputProcessor.java
@@ -22,9 +22,11 @@ import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.time.Instant;
-import java.time.temporal.ChronoField;
-import java.util.Arrays;
+import java.util.Base64;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
@@ -80,6 +82,7 @@ public class WSSSignatureReferenceVerifyInputProcessor
extends AbstractSignature
// Typed as the base class since buildTransformerChain() receives the base
type in this xmlsec version
private
AbstractSignatureReferenceVerifyInputProcessor.InternalSignatureReferenceVerifier
completedReferenceVerifier;
private boolean replayChecked = false;
+ private ReplayCacheEntry pendingReplayCacheEntry;
public WSSSignatureReferenceVerifyInputProcessor(InputProcessorChain
inputProcessorChain,
SignatureType signatureType, InboundSecurityToken
inboundSecurityToken,
@@ -272,11 +275,31 @@ public class WSSSignatureReferenceVerifyInputProcessor
extends AbstractSignature
//this is the earliest possible point to check for an replay attack
if (!replayChecked) {
replayChecked = true;
- detectReplayAttack(inputProcessorChain);
+ pendingReplayCacheEntry = detectReplayAttack(inputProcessorChain);
}
return super.processEvent(inputProcessorChain);
}
+ @Override
+ public void doFinal(InputProcessorChain inputProcessorChain) throws
XMLStreamException, XMLSecurityException {
+ super.doFinal(inputProcessorChain);
+
+ // Every Reference digest has been verified by the time
super.doFinal() returns, so this is
+ // the first point at which the signature is known to be good as a
whole. Only now may the
+ // identifier be added to the replay cache: adding it while the
content was still streaming
+ // let an attacker poison the cache with the identifier of a message
whose references do
+ // not verify, so that the genuine message was afterwards rejected as
a replay.
+ if (pendingReplayCacheEntry == null) {
+ // A Timestamp that follows the Signature in the security header
had not been processed
+ // yet when the check first ran, but it has been by now.
+ pendingReplayCacheEntry = detectReplayAttack(inputProcessorChain);
+ }
+ if (pendingReplayCacheEntry != null) {
+ pendingReplayCacheEntry.add();
+ pendingReplayCacheEntry = null;
+ }
+ }
+
@Override
protected void processElementPath(List<QName> elementPath,
InputProcessorChain inputProcessorChain,
XMLSecEvent xmlSecEvent, ReferenceType
referenceType)
@@ -316,25 +339,70 @@ public class WSSSignatureReferenceVerifyInputProcessor
extends AbstractSignature
inputProcessorChain, referenceType, startElement);
}
- private void detectReplayAttack(InputProcessorChain inputProcessorChain)
throws WSSecurityException {
+ /**
+ * Test for a replayed message. Returns a pending ReplayCacheEntry, to be
added to the cache
+ * once every Reference has been verified, or null if no replay checking
was performed.
+ */
+ private ReplayCacheEntry detectReplayAttack(InputProcessorChain
inputProcessorChain) throws WSSecurityException {
TimestampSecurityEvent timestampSecurityEvent =
inputProcessorChain.getSecurityContext().get(WSSConstants.PROP_TIMESTAMP_SECURITYEVENT);
ReplayCache replayCache =
((WSSSecurityProperties)getSecurityProperties()).getTimestampReplayCache();
- if (timestampSecurityEvent != null && replayCache != null) {
- final String cacheKey =
-
timestampSecurityEvent.getCreated().get(ChronoField.MILLI_OF_SECOND)
- + "" +
Arrays.hashCode(getSignatureType().getSignatureValue().getValue());
- if (replayCache.contains(cacheKey)) {
- throw new
WSSecurityException(WSSecurityException.ErrorCode.MESSAGE_EXPIRED);
- }
+ if (timestampSecurityEvent == null || replayCache == null) {
+ return null;
+ }
+
+ final String cacheKey =
createIdentifier(timestampSecurityEvent.getCreated(),
+
getSignatureType().getSignatureValue().getValue());
+ if (replayCache.contains(cacheKey)) {
+ throw new
WSSecurityException(WSSecurityException.ErrorCode.MESSAGE_EXPIRED);
+ }
+
+ // Return the Timestamp/SignatureValue combination so that it can be
stored in the cache
+ // once the signature has been verified
+ return new ReplayCacheEntry(replayCache, cacheKey,
timestampSecurityEvent.getExpires());
+ }
+
+ /**
+ * Create the replay cache identifier for the given Timestamp Created
value and signature
+ * value. The two are separated by a '|', which can appear in neither a
Created value nor
+ * Base64, so that they cannot run together into an ambiguous identifier.
+ */
+ private static String createIdentifier(Instant created, byte[]
signatureValue) throws WSSecurityException {
+ String identifier = (created != null ? created.toString() : "") + "|"
+ encode(signatureValue);
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return
encode(digest.digest(identifier.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException ex) {
+ throw new
WSSecurityException(WSSecurityException.ErrorCode.FAILURE, ex);
+ }
+ }
+
+ private static String encode(byte[] value) {
+ return value == null ? "" : Base64.getEncoder().encodeToString(value);
+ }
+
+ /**
+ * A pending replay cache addition: the Timestamp/SignatureValue
identifier of the current
+ * message, to be added to the ReplayCache only once the signature it was
derived from has
+ * been successfully verified.
+ */
+ private static final class ReplayCacheEntry {
+ private final ReplayCache replayCache;
+ private final String identifier;
+ private final Instant expires;
+
+ ReplayCacheEntry(ReplayCache replayCache, String identifier, Instant
expires) {
+ this.replayCache = replayCache;
+ this.identifier = identifier;
+ this.expires = expires;
+ }
- // Store the Timestamp/SignatureValue combination in the cache
- Instant expires = timestampSecurityEvent.getExpires();
+ void add() {
if (expires != null) {
- replayCache.add(cacheKey, expires);
+ replayCache.add(identifier, expires);
} else {
- replayCache.add(cacheKey);
+ replayCache.add(identifier);
}
}
}
diff --git
a/ws-security-stax/src/test/java/org/apache/wss4j/stax/test/ReplayTest.java
b/ws-security-stax/src/test/java/org/apache/wss4j/stax/test/ReplayTest.java
index df62e2add..24a02bbf2 100644
--- a/ws-security-stax/src/test/java/org/apache/wss4j/stax/test/ReplayTest.java
+++ b/ws-security-stax/src/test/java/org/apache/wss4j/stax/test/ReplayTest.java
@@ -21,6 +21,7 @@ package org.apache.wss4j.stax.test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Properties;
@@ -50,6 +51,7 @@ import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
@@ -123,6 +125,97 @@ public class ReplayTest extends AbstractTestBase {
replayCache.close();
}
+ /**
+ * A message whose Signature fails to verify must not leave its identifier
in the replay
+ * cache. Otherwise an attacker can take a genuine message, tamper with a
signed part of it so
+ * that a Reference no longer verifies, and send that first: the tampered
message is rejected,
+ * but it has claimed the genuine message's identifier - the Timestamp and
SignatureValue are
+ * untouched - so the genuine message is then rejected as a replay when it
arrives.
+ */
+ @Test
+ public void testReplayCacheNotPoisonedByInvalidSignature() throws
Exception {
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ {
+ InputStream sourceDocument =
this.getClass().getClassLoader().getResourceAsStream("testdata/plain-soap-1.1.xml");
+ String action = WSHandlerConstants.SIGNATURE + " " +
WSHandlerConstants.TIMESTAMP;
+ Properties properties = new Properties();
+ properties.setProperty(WSHandlerConstants.SIGNATURE_PARTS,
+ "{Element}{" + WSConstants.WSU_NS + "}Timestamp;"
+ +
"{Element}{http://schemas.xmlsoap.org/soap/envelope/}Body;");
+ Document securedDocument =
doOutboundSecurityWithWSS4J(sourceDocument, action, properties);
+
+ javax.xml.transform.Transformer transformer =
TRANSFORMER_FACTORY.newTransformer();
+ transformer.transform(new DOMSource(securedDocument), new
StreamResult(baos));
+ }
+
+ String genuineMessage = new String(baos.toByteArray(),
StandardCharsets.UTF_8);
+
+ // Alter a signed part of the SOAP Body. The Timestamp and the
SignatureValue - which
+ // together are the whole replay cache identifier - are left exactly
as they are in the
+ // genuine message.
+ String tamperedMessage =
+ genuineMessage.replace("comprehensive types test",
"comprehensive types TEST");
+ assertNotEquals(genuineMessage, tamperedMessage, "The signed SOAP Body
was not modified");
+
+ ReplayCache replayCache = createCache("wss4j.timestamp.cache-");
+
+ //the tampered message must be rejected, as one of its References no
longer verifies
+ {
+ WSSSecurityProperties securityProperties = new
WSSSecurityProperties();
+ securityProperties.setTimestampReplayCache(replayCache);
+
securityProperties.loadSignatureVerificationKeystore(this.getClass().getClassLoader().getResource("receiver.jks"),
"default".toCharArray());
+ InboundWSSec wsSecIn = WSSec.getInboundWSSec(securityProperties,
false, true);
+ XMLStreamReader xmlStreamReader = wsSecIn.processInMessage(
+ xmlInputFactory.createXMLStreamReader(
+ new
ByteArrayInputStream(tamperedMessage.getBytes(StandardCharsets.UTF_8))));
+
+ try {
+ StAX2DOM.readDoc(documentBuilderFactory.newDocumentBuilder(),
xmlStreamReader);
+ fail("Exception expected");
+ } catch (XMLStreamException e) {
+ assertTrue(e.getCause() instanceof XMLSecurityException);
+ }
+ }
+
+ //...and it must not have cached the genuine message's identifier on
its way out
+ {
+ WSSSecurityProperties securityProperties = new
WSSSecurityProperties();
+ securityProperties.setTimestampReplayCache(replayCache);
+
securityProperties.loadSignatureVerificationKeystore(this.getClass().getClassLoader().getResource("receiver.jks"),
"default".toCharArray());
+ InboundWSSec wsSecIn = WSSec.getInboundWSSec(securityProperties);
+ XMLStreamReader xmlStreamReader = wsSecIn.processInMessage(
+ xmlInputFactory.createXMLStreamReader(
+ new
ByteArrayInputStream(genuineMessage.getBytes(StandardCharsets.UTF_8))));
+
+ Document document =
StAX2DOM.readDoc(documentBuilderFactory.newDocumentBuilder(), xmlStreamReader);
+ NodeList nodeList =
document.getElementsByTagNameNS(WSSConstants.TAG_dsig_Signature.getNamespaceURI(),
+
WSSConstants.TAG_dsig_Signature.getLocalPart());
+ assertEquals(nodeList.getLength(), 1);
+ }
+
+ //replay detection must still work for the genuine message itself
+ {
+ WSSSecurityProperties securityProperties = new
WSSSecurityProperties();
+ securityProperties.setTimestampReplayCache(replayCache);
+
securityProperties.loadSignatureVerificationKeystore(this.getClass().getClassLoader().getResource("receiver.jks"),
"default".toCharArray());
+ InboundWSSec wsSecIn = WSSec.getInboundWSSec(securityProperties,
false, true);
+ XMLStreamReader xmlStreamReader = wsSecIn.processInMessage(
+ xmlInputFactory.createXMLStreamReader(
+ new
ByteArrayInputStream(genuineMessage.getBytes(StandardCharsets.UTF_8))));
+
+ try {
+ StAX2DOM.readDoc(documentBuilderFactory.newDocumentBuilder(),
xmlStreamReader);
+ fail("Exception expected");
+ } catch (XMLStreamException e) {
+ assertTrue(e.getCause() instanceof XMLSecurityException);
+ assertEquals("The message has expired",
e.getCause().getMessage());
+ }
+ }
+
+ replayCache.close();
+ }
+
@Test
public void testUsernameToken() throws Exception {