This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/nonce-encoding in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
commit 04c0e5a7380d733bc9ed252d85d68687f67a22ab Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Thu Sep 17 10:07:41 2026 +0100 Fix nonce encoding issue --- .../wss4j/common/util/UsernameTokenUtil.java | 27 +++++ .../dom/processor/UsernameTokenProcessor.java | 29 +++-- .../org/apache/wss4j/dom/message/ReplayTest.java | 118 +++++++++++++++++++++ .../processor/input/UsernameTokenInputHandler.java | 30 ++++-- .../org/apache/wss4j/stax/test/ReplayTest.java | 79 ++++++++++++++ 5 files changed, 269 insertions(+), 14 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 95e3079b6..9989367d6 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 ea3835f97..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 + // 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 1089970bf..2a70c2c3d 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 6ba7e9e56..f45a09220 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(); //NOPMD 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
