This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch 3_0_x-fixes
in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
The following commit(s) were added to refs/heads/3_0_x-fixes by this push:
new 1c50690db Fix nonce encoding issue (#704)
1c50690db is described below
commit 1c50690db45809466f4764e2e36fb71e81d02dcc
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 | 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