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

coheigea pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git


The following commit(s) were added to refs/heads/master by this push:
     new a07804833 Fix StAX replaycache tests (#703)
a07804833 is described below

commit a07804833e5af47bf8155ac57e965abec79ee171
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 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 {
 

Reply via email to