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 58ce88baa Fix nonce encoding issue (#704)
58ce88baa is described below

commit 58ce88baaf93b20646f68afe6db6fadf731642fa
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Thu Sep 17 10:15:21 2026 +0100

    Fix nonce encoding issue (#704)
---
 .../wss4j/common/util/UsernameTokenUtil.java       |  27 +++++
 .../dom/processor/UsernameTokenProcessor.java      |  31 ++++--
 .../org/apache/wss4j/dom/message/ReplayTest.java   | 118 +++++++++++++++++++++
 .../processor/input/UsernameTokenInputHandler.java |  30 ++++--
 .../org/apache/wss4j/stax/test/ReplayTest.java     |  79 ++++++++++++++
 5 files changed, 270 insertions(+), 15 deletions(-)

diff --git 
a/ws-security-common/src/main/java/org/apache/wss4j/common/util/UsernameTokenUtil.java
 
b/ws-security-common/src/main/java/org/apache/wss4j/common/util/UsernameTokenUtil.java
index 9988278e2..5caf0c3a8 100644
--- 
a/ws-security-common/src/main/java/org/apache/wss4j/common/util/UsernameTokenUtil.java
+++ 
b/ws-security-common/src/main/java/org/apache/wss4j/common/util/UsernameTokenUtil.java
@@ -23,6 +23,7 @@ import java.io.IOException;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
+import java.util.Base64;
 
 import javax.security.auth.callback.Callback;
 import javax.security.auth.callback.CallbackHandler;
@@ -202,4 +203,30 @@ public final class UsernameTokenUtil {
         }
         return pwCb.getPassword();
     }
+
+    /**
+     * Get the canonical form of the given Nonce, for use as a replay cache 
key.
+     *
+     * The Nonce is base64, and every other part of the stack decodes it 
before use, so two Nonces
+     * that decode to the same bytes are the same Nonce however they happen to 
be written. The
+     * decoder ignores whitespace and any other character outside the base64 
alphabet, and does not
+     * reject non-zero unused trailing bits, so one Nonce has many valid 
encodings. Keying the
+     * replay cache on the raw element text would let a replayed message 
escape detection simply by
+     * rewriting its Nonce into an equivalent encoding.
+     *
+     * @param nonce the Nonce exactly as it appeared in the message
+     * @return the same Nonce re-encoded in canonical base64
+     * @throws WSSecurityException if the Nonce is not valid base64
+     */
+    public static String getCanonicalNonce(String nonce) throws 
WSSecurityException {
+        try {
+            return Base64.getEncoder().encodeToString(
+                org.apache.xml.security.utils.XMLUtils.decode(nonce));
+        } catch (IllegalArgumentException ex) {
+            LOG.debug(ex.getMessage(), ex);
+            throw new WSSecurityException(
+                WSSecurityException.ErrorCode.INVALID_SECURITY, ex, 
"badUsernameToken",
+                new Object[] {"The Nonce is not valid Base-64"});
+        }
+    }
 }
diff --git 
a/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
 
b/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
index 5222e6927..98713b5e2 100644
--- 
a/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
+++ 
b/ws-security-dom/src/main/java/org/apache/wss4j/dom/processor/UsernameTokenProcessor.java
@@ -144,10 +144,16 @@ public class UsernameTokenProcessor implements Processor {
             throw new 
WSSecurityException(WSSecurityException.ErrorCode.MESSAGE_EXPIRED);
         }
 
-        // Test for replay attacks
-        ReplayCache replayCache = data.getNonceReplayCache();
+        // Test for replay attacks. The cache is keyed on the canonical form 
of the Nonce rather
+        // than on its raw element text: the Nonce is base64 and is decoded 
before it is used to
+        // verify the password digest, so a replayed token whose Nonce has 
merely been rewritten
+        // into an equivalent encoding would otherwise miss the cache and be 
accepted again.
+        ReplayCache replayCache = data.getNonceReplayCache();   //NOPMD
+        String nonceIdentifier = null;
+        Instant nonceExpiry = null;
         if (replayCache != null && ut.getNonce() != null) {
-            if (replayCache.contains(ut.getNonce())) {
+            nonceIdentifier = 
UsernameTokenUtil.getCanonicalNonce(ut.getNonce());
+            if (replayCache.contains(nonceIdentifier)) {
                 throw new WSSecurityException(
                     WSSecurityException.ErrorCode.INVALID_SECURITY,
                     "badUsernameToken",
@@ -159,17 +165,26 @@ public class UsernameTokenProcessor implements Processor {
             // Otherwise, cache for the configured TTL of the UsernameToken 
Created time, as any
             // older token will just get rejected anyway
             Instant created = ut.getCreatedDate();
-            if (created == null || utTTL <= 0) {
-                replayCache.add(ut.getNonce());
-            } else {
-                replayCache.add(ut.getNonce(), 
Instant.now().plusSeconds(utTTL));
+            if (created != null && utTTL > 0) {
+                nonceExpiry = Instant.now().plusSeconds(utTTL);
             }
         }
 
         Credential credential = new Credential();
         credential.setUsernametoken(ut);
         if (validator != null) {
-            return validator.validate(credential, data);
+            credential = validator.validate(credential, data);
+        }
+
+        // Only cache the Nonce once the token has actually been validated. 
Caching it beforehand
+        // lets an attacker poison the cache with the Nonce of a token whose 
password does not
+        // verify, so that the genuine token is subsequently rejected as a 
replay.
+        if (nonceIdentifier != null) {
+            if (nonceExpiry != null) {
+                replayCache.add(nonceIdentifier, nonceExpiry);
+            } else {
+                replayCache.add(nonceIdentifier);
+            }
         }
         return credential;
     }
diff --git 
a/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java 
b/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java
index 037625419..e477f7d41 100644
--- a/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java
+++ b/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/ReplayTest.java
@@ -21,6 +21,8 @@ package org.apache.wss4j.dom.message;
 
 import java.nio.file.Path;
 import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import javax.security.auth.callback.CallbackHandler;
 
@@ -55,6 +57,7 @@ import org.junit.jupiter.api.io.TempDir;
 import org.w3c.dom.Document;
 import org.w3c.dom.Element;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -552,6 +555,121 @@ public class ReplayTest {
         }
     }
 
+    /**
+     * The Nonce replay cache must be keyed on the decoded Nonce, not on the 
raw text of the Nonce
+     * element. Base64 decoding ignores whitespace, so a replayed token whose 
Nonce has merely been
+     * line-wrapped still authenticates - the password digest is computed over 
the decoded bytes -
+     * and must still be detected as a replay.
+     */
+    @Test
+    public void testReplayedUsernameTokenWithEquivalentNonceEncoding() throws 
Exception {
+        Document doc = SOAPUtil.toSOAPPart(SOAPUtil.SAMPLE_SOAP_MSG);
+        WSSecHeader secHeader = new WSSecHeader(doc);
+        secHeader.insertSecurityHeader();
+
+        WSSecUsernameToken builder = new WSSecUsernameToken(secHeader);
+        builder.setUserInfo("wernerd", "verySecret");
+
+        Document signedDoc = builder.build();
+
+        String genuineMessage = XMLUtils.prettyDocumentToString(signedDoc);
+        if (LOG.isDebugEnabled()) {
+            LOG.debug(genuineMessage);
+        }
+
+        Matcher matcher = 
Pattern.compile("(<[^>]*Nonce[^>]*>)([^<]*)(</)").matcher(genuineMessage);
+        assertTrue(matcher.find(), "No Nonce element was found");
+        String nonce = matcher.group(2);
+
+        // Wrap the Nonce across two lines. Base64 decoding ignores the 
newline, so this Nonce
+        // decodes to exactly the same bytes and the password digest still 
verifies.
+        String equivalentNonce = nonce.substring(0, 4) + "\n" + 
nonce.substring(4);
+        assertNotEquals(nonce, equivalentNonce);
+        assertArrayEquals(org.apache.xml.security.utils.XMLUtils.decode(nonce),
+                          
org.apache.xml.security.utils.XMLUtils.decode(equivalentNonce),
+                          "The rewritten Nonce must decode to the same bytes");
+
+        String replayedMessage = genuineMessage.substring(0, matcher.start(2))
+            + equivalentNonce + genuineMessage.substring(matcher.end(2));
+
+        WSSConfig wssConfig = WSSConfig.getNewInstance();
+        RequestData data = new RequestData();
+        data.setCallbackHandler(new UsernamePasswordCallbackHandler());
+        data.setWssConfig(wssConfig);
+        data.setNonceReplayCache(new MemoryReplayCache());
+
+        // Successfully verify the genuine UsernameToken
+        verify(SOAPUtil.toSOAPPart(genuineMessage), wssConfig, data);
+
+        // The rewritten Nonce decodes to the same bytes, so this is a replay
+        try {
+            verify(SOAPUtil.toSOAPPart(replayedMessage), wssConfig, data);
+            fail("Expected failure on a replay attack");
+        } catch (WSSecurityException ex) {
+            assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY, 
ex.getErrorCode());
+        }
+    }
+
+    /**
+     * A UsernameToken whose password fails to verify must not leave its Nonce 
in the replay cache.
+     * Otherwise an attacker can replay a genuine token with a corrupted 
Password and send that
+     * first: it is rejected, but it has claimed the genuine token's Nonce, so 
the genuine token is
+     * then rejected as a replay when it arrives.
+     */
+    @Test
+    public void testNonceCacheNotPoisonedByInvalidPassword() throws Exception {
+        Document doc = SOAPUtil.toSOAPPart(SOAPUtil.SAMPLE_SOAP_MSG);
+        WSSecHeader secHeader = new WSSecHeader(doc);
+        secHeader.insertSecurityHeader();
+
+        WSSecUsernameToken builder = new WSSecUsernameToken(secHeader);
+        builder.setUserInfo("wernerd", "verySecret");
+
+        Document signedDoc = builder.build();
+
+        String genuineMessage = XMLUtils.prettyDocumentToString(signedDoc);
+        if (LOG.isDebugEnabled()) {
+            LOG.debug(genuineMessage);
+        }
+
+        // Corrupt the password digest, leaving the Nonce - the whole cache 
key - untouched
+        Matcher matcher =
+            
Pattern.compile("(<[^>]*Password[^>]*>)([^<]*)(</)").matcher(genuineMessage);
+        assertTrue(matcher.find(), "No Password element was found");
+        String password = matcher.group(2);
+        String corruptedPassword =
+            (password.charAt(0) == 'A' ? "B" : "A") + password.substring(1);
+
+        String tamperedMessage = genuineMessage.substring(0, matcher.start(2))
+            + corruptedPassword + genuineMessage.substring(matcher.end(2));
+        assertNotEquals(genuineMessage, tamperedMessage);
+
+        WSSConfig wssConfig = WSSConfig.getNewInstance();
+        RequestData data = new RequestData();
+        data.setCallbackHandler(new UsernamePasswordCallbackHandler());
+        data.setWssConfig(wssConfig);
+        data.setNonceReplayCache(new MemoryReplayCache());
+
+        // The tampered token must be rejected, as its password digest no 
longer verifies
+        try {
+            verify(SOAPUtil.toSOAPPart(tamperedMessage), wssConfig, data);
+            fail("Expected failure on a tampered UsernameToken");
+        } catch (WSSecurityException ex) {
+            assertEquals(WSSecurityException.ErrorCode.FAILED_AUTHENTICATION, 
ex.getErrorCode());
+        }
+
+        // ...and it must not have cached the genuine token's Nonce on its way 
out
+        verify(SOAPUtil.toSOAPPart(genuineMessage), wssConfig, data);
+
+        // Replay detection must still work for the genuine token itself
+        try {
+            verify(SOAPUtil.toSOAPPart(genuineMessage), wssConfig, data);
+            fail("Expected failure on a replay attack");
+        } catch (WSSecurityException ex) {
+            assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY, 
ex.getErrorCode());
+        }
+    }
+
     @Test
     public void testEhCacheReplayedUsernameToken() throws Exception {
         Document doc = SOAPUtil.toSOAPPart(SOAPUtil.SAMPLE_SOAP_MSG);
diff --git 
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
 
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
index c28d506b1..7fcd1212b 100644
--- 
a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
+++ 
b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/processor/input/UsernameTokenInputHandler.java
@@ -26,6 +26,7 @@ import org.apache.wss4j.common.bsp.BSPRule;
 import org.apache.wss4j.common.cache.ReplayCache;
 import org.apache.wss4j.common.ext.WSSecurityException;
 import org.apache.wss4j.common.util.DateUtil;
+import org.apache.wss4j.common.util.UsernameTokenUtil;
 import org.apache.wss4j.stax.ext.WSInboundSecurityContext;
 import org.apache.wss4j.stax.ext.WSSConstants;
 import org.apache.wss4j.stax.ext.WSSSecurityProperties;
@@ -79,10 +80,16 @@ public class UsernameTokenInputHandler extends 
AbstractInputSecurityHeaderHandle
         ReplayCache replayCache = wssSecurityProperties.getNonceReplayCache();
         final EncodedString encodedNonce =
                 XMLSecurityUtils.getQNameType(usernameTokenType.getAny(), 
WSSConstants.TAG_WSSE_NONCE);
+        String nonceIdentifier = null;
+        Instant nonceExpiry = null;
         if (encodedNonce != null && replayCache != null) {
-            // Check for replay attacks
-            String nonce = encodedNonce.getValue();
-            if (replayCache.contains(nonce)) {
+            // Check for replay attacks. The cache is keyed on the canonical 
form of the Nonce
+            // rather than on its raw element text: the Nonce is base64 and is 
decoded before it is
+            // used to verify the password digest, so a replayed token whose 
Nonce has merely been
+            // rewritten into an equivalent encoding would otherwise miss the 
cache and be accepted
+            // again.
+            nonceIdentifier = 
UsernameTokenUtil.getCanonicalNonce(encodedNonce.getValue());
+            if (replayCache.contains(nonceIdentifier)) {
                 throw new 
WSSecurityException(WSSecurityException.ErrorCode.FAILED_AUTHENTICATION);
             }
 
@@ -90,10 +97,8 @@ public class UsernameTokenInputHandler extends 
AbstractInputSecurityHeaderHandle
             // Otherwise, cache for the configured TTL of the UsernameToken 
Created time, as any
             // older token will just get rejected anyway
             int utTTL = wssSecurityProperties.getUtTTL();
-            if (created == null || utTTL <= 0) {
-                replayCache.add(nonce);
-            } else {
-                replayCache.add(nonce, Instant.now().plusSeconds(utTTL));
+            if (created != null && utTTL > 0) {
+                nonceExpiry = Instant.now().plusSeconds(utTTL);
             }
         }
 
@@ -112,6 +117,17 @@ public class UsernameTokenInputHandler extends 
AbstractInputSecurityHeaderHandle
         final UsernameSecurityToken usernameSecurityToken =
                 usernameTokenValidator.validate(usernameTokenType, 
tokenContext);
 
+        // Only cache the Nonce once the token has actually been validated. 
Caching it beforehand
+        // lets an attacker poison the cache with the Nonce of a token whose 
password does not
+        // verify, so that the genuine token is subsequently rejected as a 
replay.
+        if (nonceIdentifier != null) {
+            if (nonceExpiry != null) {
+                replayCache.add(nonceIdentifier, nonceExpiry);
+            } else {
+                replayCache.add(nonceIdentifier);
+            }
+        }
+
         SecurityTokenProvider<InboundSecurityToken> securityTokenProvider =
                 new SecurityTokenProvider<InboundSecurityToken>() {
 
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 24a02bbf2..e07ff0ff8 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
@@ -24,6 +24,8 @@ import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.util.Properties;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import javax.xml.stream.XMLStreamException;
 import javax.xml.stream.XMLStreamReader;
@@ -50,6 +52,7 @@ import org.junit.jupiter.api.io.TempDir;
 import org.w3c.dom.Document;
 import org.w3c.dom.NodeList;
 
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -270,6 +273,82 @@ public class ReplayTest extends AbstractTestBase {
         replayCache.close();
     }
 
+    /**
+     * The Nonce replay cache must be keyed on the decoded Nonce, not on the 
raw text of the Nonce
+     * element. Base64 decoding ignores whitespace, so a replayed token whose 
Nonce has merely been
+     * line-wrapped still authenticates - the password digest is computed over 
the decoded bytes -
+     * and must still be detected as a replay.
+     */
+    @Test
+    public void testReplayedUsernameTokenWithEquivalentNonceEncoding() throws 
Exception {
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        {
+            InputStream sourceDocument = 
this.getClass().getClassLoader().getResourceAsStream("testdata/plain-soap-1.1.xml");
+            String action = WSHandlerConstants.USERNAME_TOKEN;
+            Properties properties = new Properties();
+            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);
+
+        Matcher matcher = 
Pattern.compile("(<[^>]*Nonce[^>]*>)([^<]*)(</)").matcher(genuineMessage);
+        assertTrue(matcher.find(), "No Nonce element was found");
+        String nonce = matcher.group(2);
+
+        // Wrap the Nonce across two lines. Base64 decoding ignores the 
newline, so this Nonce
+        // decodes to exactly the same bytes and the password digest still 
verifies.
+        String equivalentNonce = nonce.substring(0, 4) + "\n" + 
nonce.substring(4);
+        assertNotEquals(nonce, equivalentNonce);
+        assertArrayEquals(org.apache.xml.security.utils.XMLUtils.decode(nonce),
+                          
org.apache.xml.security.utils.XMLUtils.decode(equivalentNonce),
+                          "The rewritten Nonce must decode to the same bytes");
+
+        String replayedMessage = genuineMessage.substring(0, matcher.start(2))
+            + equivalentNonce + genuineMessage.substring(matcher.end(2));
+
+        ReplayCache replayCache = createCache("wss4j.nonce.cache-");
+
+        //the genuine UsernameToken must verify
+        {
+            WSSSecurityProperties securityProperties = new 
WSSSecurityProperties();
+            securityProperties.setNonceReplayCache(replayCache);
+            securityProperties.setCallbackHandler(new CallbackHandlerImpl());
+            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_WSSE_USERNAME_TOKEN.getNamespaceURI(),
+                                                                
WSSConstants.TAG_WSSE_USERNAME_TOKEN.getLocalPart());
+            assertEquals(nodeList.getLength(), 1);
+        }
+
+        //the rewritten Nonce decodes to the same bytes, so this is a replay
+        {
+            WSSSecurityProperties securityProperties = new 
WSSSecurityProperties();
+            securityProperties.setNonceReplayCache(replayCache);
+            securityProperties.setCallbackHandler(new CallbackHandlerImpl());
+            InboundWSSec wsSecIn = WSSec.getInboundWSSec(securityProperties);
+            XMLStreamReader xmlStreamReader = wsSecIn.processInMessage(
+                    xmlInputFactory.createXMLStreamReader(
+                            new 
ByteArrayInputStream(replayedMessage.getBytes(StandardCharsets.UTF_8))));
+
+            try {
+                StAX2DOM.readDoc(documentBuilderFactory.newDocumentBuilder(), 
xmlStreamReader);
+                fail("Exception expected");
+            } catch (XMLStreamException e) {
+                assertTrue(e.getCause() instanceof XMLSecurityException);
+            }
+        }
+
+        replayCache.close();
+    }
+
     /**
      * Test that creates, sends and processes an unsigned SAML 2 
authentication assertion. This
      * is just a sanity test to make sure that it is possible to send the SAML 
token twice, as

Reply via email to