You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ws.apache.org by co...@apache.org on 2026/09/17 08:21:11 UTC
(ws-wss4j) branch coheigea/stax-replay-cache created (now d50458933)
This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a change to branch coheigea/stax-replay-cache
in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
at d50458933 Fix StAX replaycache tests
This branch includes the following new commits:
new d50458933 Fix StAX replaycache tests
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(ws-wss4j) 01/01: Fix StAX replaycache tests
Posted by co...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch coheigea/stax-replay-cache
in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
commit d50458933e54b97f0a2e8498e3d3d8fde44e781e
Author: Colm O hEigeartaigh <co...@apache.org>
AuthorDate: Thu Sep 17 09:20:47 2026 +0100
Fix StAX replaycache tests
---
.../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 4e77e9cdf..e85f9b27a 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;
@@ -79,6 +81,7 @@ public class WSSSignatureReferenceVerifyInputProcessor extends AbstractSignature
private InternalSignatureReferenceVerifier completedReferenceVerifier;
private boolean replayChecked = false;
+ private ReplayCacheEntry pendingReplayCacheEntry;
public WSSSignatureReferenceVerifyInputProcessor(InputProcessorChain inputProcessorChain,
SignatureType signatureType, InboundSecurityToken inboundSecurityToken,
@@ -268,11 +271,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)
@@ -312,25 +335,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 = //NOPMD
((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 {