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 <[email protected]> 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 {
