You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ws.apache.org by co...@apache.org on 2026/09/18 10:38:41 UTC

(ws-wss4j) branch 3_0_x-fixes updated: Putting max on UsernameToken stax iterations (#707)

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 24258ddf1 Putting max on UsernameToken stax iterations (#707)
24258ddf1 is described below

commit 24258ddf115f0dbfa4af8066ba3011165ae193e2
Author: Colm O hEigeartaigh <co...@users.noreply.github.com>
AuthorDate: Fri Sep 18 11:38:13 2026 +0100

    Putting max on UsernameToken stax iterations (#707)
---
 .../wss4j/common/util/UsernameTokenUtil.java       |   8 ++
 .../wss4j/dom/message/token/UsernameToken.java     |   6 +-
 .../apache/wss4j/dom/message/UTDerivedKeyTest.java |  40 ++++++
 .../securityToken/UsernameSecurityTokenImpl.java   |  15 ++
 .../stax/test/UsernameTokenIterationTest.java      | 156 +++++++++++++++++++++
 5 files changed, 223 insertions(+), 2 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 9989367d6..c2f0dd9d7 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
@@ -36,6 +36,14 @@ import org.apache.xml.security.stax.ext.XMLSecurityConstants;
 public final class UsernameTokenUtil {
     public static final int DEFAULT_ITERATION = 1000;
 
+    /**
+     * The maximum number of hash rounds that an inbound UsernameToken may request through its
+     * wsse11:Iteration element. The Iteration value is attacker-controlled message content and
+     * the key derivation below performs one SHA-1 round per iteration, so leaving it unbounded
+     * lets a small request buy an arbitrary amount of CPU time on the receiver.
+     */
+    public static final int MAX_ITERATION = 10000;
+
     private static final org.slf4j.Logger LOG =
             org.slf4j.LoggerFactory.getLogger(UsernameTokenUtil.class);
 
diff --git a/ws-security-dom/src/main/java/org/apache/wss4j/dom/message/token/UsernameToken.java b/ws-security-dom/src/main/java/org/apache/wss4j/dom/message/token/UsernameToken.java
index a008bd902..71c2c7698 100644
--- a/ws-security-dom/src/main/java/org/apache/wss4j/dom/message/token/UsernameToken.java
+++ b/ws-security-dom/src/main/java/org/apache/wss4j/dom/message/token/UsernameToken.java
@@ -153,11 +153,13 @@ public class UsernameToken {
             if (iter != null) {
                 try {
                     iteration = Integer.parseInt(iter);
-                    if (iteration < 0 || iteration > 10000) {
+                    if (iteration < 0 || iteration > UsernameTokenUtil.MAX_ITERATION) {
                         throw new WSSecurityException(
                             WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN,
                             "badUsernameToken",
-                            new Object[] {"Iteration is missing"}
+                            new Object[] {"Iteration of " + iteration
+                                          + " is outside the allowed range [0, "
+                                          + UsernameTokenUtil.MAX_ITERATION + "]"}
                         );
                     }
                 } catch (NumberFormatException ex) {
diff --git a/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/UTDerivedKeyTest.java b/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/UTDerivedKeyTest.java
index aa6e4f701..552de57fb 100644
--- a/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/UTDerivedKeyTest.java
+++ b/ws-security-dom/src/test/java/org/apache/wss4j/dom/message/UTDerivedKeyTest.java
@@ -25,6 +25,7 @@ import java.util.Collections;
 
 import javax.security.auth.callback.CallbackHandler;
 
+import org.apache.wss4j.common.bsp.BSPEnforcer;
 import org.apache.wss4j.common.bsp.BSPRule;
 import org.apache.wss4j.common.crypto.Crypto;
 import org.apache.wss4j.common.crypto.CryptoFactory;
@@ -47,9 +48,12 @@ import org.apache.wss4j.dom.util.WSSecurityUtil;
 
 import org.junit.jupiter.api.Test;
 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
@@ -839,6 +843,42 @@ public class UTDerivedKeyTest {
     }
 
 
+    /**
+     * The wsse11:Iteration value is attacker-controlled message content and the key derivation
+     * performs one SHA-1 round per iteration, so the parser bounds it. Regression test for the
+     * bound itself, which is shared with the streaming engine
+     * (see UsernameTokenUtil.MAX_ITERATION).
+     */
+    @Test
+    public void testIterationAboveMaximumIsRejected() throws Exception {
+        Element tokenElement = buildDerivedKeyTokenElement(UsernameTokenUtil.MAX_ITERATION + 1);
+
+        WSSecurityException exception = assertThrows(WSSecurityException.class,
+            () -> new UsernameToken(tokenElement, false, new BSPEnforcer(true)));
+        assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN, exception.getErrorCode());
+    }
+
+    @Test
+    public void testMaximumIterationIsAccepted() throws Exception {
+        Element tokenElement = buildDerivedKeyTokenElement(UsernameTokenUtil.MAX_ITERATION);
+
+        UsernameToken token = new UsernameToken(tokenElement, false, new BSPEnforcer(true));
+        assertEquals(UsernameTokenUtil.MAX_ITERATION, token.getIteration());
+    }
+
+    private Element buildDerivedKeyTokenElement(int iteration) throws Exception {
+        Document doc = SOAPUtil.toSOAPPart(SOAPUtil.SAMPLE_SOAP_MSG);
+        WSSecHeader secHeader = new WSSecHeader(doc);
+        secHeader.insertSecurityHeader();
+
+        WSSecUsernameToken builder = new WSSecUsernameToken(secHeader);
+        builder.setUserInfo("bob", "security");
+        builder.addDerivedKey(iteration);
+        builder.prepare(UsernameTokenUtil.generateSalt(false));
+
+        return builder.getUsernameTokenElement();
+    }
+
     /**
      * Verifies the soap envelope.
      *
diff --git a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/securityToken/UsernameSecurityTokenImpl.java b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/securityToken/UsernameSecurityTokenImpl.java
index e83a831f1..c06d5edbf 100644
--- a/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/securityToken/UsernameSecurityTokenImpl.java
+++ b/ws-security-stax/src/main/java/org/apache/wss4j/stax/impl/securityToken/UsernameSecurityTokenImpl.java
@@ -115,6 +115,21 @@ public class UsernameSecurityTokenImpl extends AbstractInboundSecurityToken impl
      */
     protected byte[] generateDerivedKey(WSInboundSecurityContext wsInboundSecurityContext) throws WSSecurityException {
 
+        // Guard against a malicious user sending a bogus iteration value. The derivation performs
+        // one SHA-1 round per iteration, so an unbounded value turns a small request into an
+        // arbitrary amount of CPU work on the receiver. The DOM code rejects anything outside this
+        // range while parsing the token (see UsernameToken); this is the streaming equivalent.
+        //
+        // The bound is applied to the Long before it is narrowed to an int, so that a value larger
+        // than Integer.MAX_VALUE cannot wrap round into a small, acceptable-looking iteration count.
+        if (iteration != null && (iteration < 0 || iteration > UsernameTokenUtil.MAX_ITERATION)) {
+            throw new WSSecurityException(
+                WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN,
+                "badUsernameToken",
+                new Object[] {"Iteration of " + iteration + " is outside the allowed range [0, "
+                              + UsernameTokenUtil.MAX_ITERATION + "]"});
+        }
+
         if (wsInboundSecurityContext != null) {
             if (salt == null || salt.length == 0) {
                 wsInboundSecurityContext.handleBSPRule(BSPRule.R4217);
diff --git a/ws-security-stax/src/test/java/org/apache/wss4j/stax/test/UsernameTokenIterationTest.java b/ws-security-stax/src/test/java/org/apache/wss4j/stax/test/UsernameTokenIterationTest.java
new file mode 100644
index 000000000..6e9b6b987
--- /dev/null
+++ b/ws-security-stax/src/test/java/org/apache/wss4j/stax/test/UsernameTokenIterationTest.java
@@ -0,0 +1,156 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.wss4j.stax.test;
+
+import java.time.Duration;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+
+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.impl.InboundWSSecurityContextImpl;
+import org.apache.wss4j.stax.impl.securityToken.UsernameSecurityTokenImpl;
+import org.apache.wss4j.stax.securityToken.WSSecurityTokenConstants;
+import org.apache.wss4j.stax.setup.WSSec;
+import org.apache.xml.security.stax.impl.util.IDGenerator;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
+
+/**
+ * Tests that the wsse11:Iteration value of an inbound UsernameToken is bounded before it is used
+ * to derive a key. The value is attacker-controlled message content and the derivation performs
+ * one SHA-1 round per iteration, so without a bound a ~1 KB request buys an arbitrary amount of
+ * CPU time on the receiver. The DOM engine has always rejected out-of-range values while parsing
+ * the token; these tests cover the streaming equivalent.
+ */
+public class UsernameTokenIterationTest {
+
+    private static final byte[] SALT = new byte[16];
+
+    @BeforeAll
+    public static void setUp() throws Exception {
+        WSSec.init();
+    }
+
+    private UsernameSecurityTokenImpl createToken(Long iteration, WSInboundSecurityContext context) {
+        String created =
+            DateUtil.getDateTimeFormatter(true).format(ZonedDateTime.now(ZoneOffset.UTC));
+        return new UsernameSecurityTokenImpl(
+            WSSConstants.UsernameTokenPasswordType.PASSWORD_NONE,
+            "username", "password", created, null, SALT, iteration,
+            context, IDGenerator.generateID(null),
+            WSSecurityTokenConstants.KEYIDENTIFIER_SECURITY_TOKEN_DIRECT_REFERENCE);
+    }
+
+    @Test
+    public void testIterationAboveMaximumIsRejected() {
+        UsernameSecurityTokenImpl token =
+            createToken((long)UsernameTokenUtil.MAX_ITERATION + 1, null);
+
+        WSSecurityException exception =
+            assertThrows(WSSecurityException.class, token::generateDerivedKey);
+        assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN, exception.getErrorCode());
+    }
+
+    /**
+     * The amplification case: a single small request asking for ~2^31 SHA-1 rounds. The bound has
+     * to reject it rather than perform the work, so the derivation must return promptly. The
+     * timeout is generous - the guarded path fails in microseconds, whereas actually running
+     * Integer.MAX_VALUE rounds takes minutes - so this only fails if the bound is gone.
+     */
+    @Test
+    public void testHugeIterationIsRejectedWithoutDoingTheWork() {
+        UsernameSecurityTokenImpl token = createToken((long)Integer.MAX_VALUE, null);
+
+        assertTimeoutPreemptively(Duration.ofSeconds(60), () -> {
+            WSSecurityException exception =
+                assertThrows(WSSecurityException.class, token::generateDerivedKey);
+            assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN,
+                         exception.getErrorCode());
+        });
+    }
+
+    /**
+     * An Iteration larger than Integer.MAX_VALUE must be rejected outright. It must not narrow to
+     * a negative or zero int and thereby be silently accepted as the default iteration count -
+     * which is what happens if the bound is applied after the Long has been converted to an int.
+     */
+    @Test
+    public void testIterationAboveIntegerRangeIsRejected() {
+        for (long iteration : new long[] {Integer.MAX_VALUE + 1L, 1L << 32, Long.MAX_VALUE}) {
+            UsernameSecurityTokenImpl token = createToken(iteration, null);
+
+            WSSecurityException exception =
+                assertThrows(WSSecurityException.class, token::generateDerivedKey,
+                             "Iteration " + iteration + " should have been rejected");
+            assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN,
+                         exception.getErrorCode());
+        }
+    }
+
+    @Test
+    public void testNegativeIterationIsRejected() {
+        UsernameSecurityTokenImpl token = createToken(-1L, null);
+
+        WSSecurityException exception =
+            assertThrows(WSSecurityException.class, token::generateDerivedKey);
+        assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN, exception.getErrorCode());
+    }
+
+    @Test
+    public void testMaximumIterationIsAccepted() throws Exception {
+        UsernameSecurityTokenImpl token =
+            createToken((long)UsernameTokenUtil.MAX_ITERATION, null);
+
+        assertEquals(20, token.generateDerivedKey().length);
+    }
+
+    @Test
+    public void testDefaultIterationIsAccepted() throws Exception {
+        UsernameSecurityTokenImpl token = createToken((long)UsernameTokenUtil.DEFAULT_ITERATION, null);
+
+        assertEquals(20, token.generateDerivedKey().length);
+    }
+
+    /**
+     * The bound is an engine-level limit, not a BSP rule, so turning BSP enforcement off must not
+     * re-open it.
+     */
+    @Test
+    public void testBoundIsEnforcedWithBSPEnforcementDisabled() {
+        InboundWSSecurityContextImpl securityContext = new InboundWSSecurityContextImpl();
+        securityContext.setDisableBSPEnforcement(true);
+
+        UsernameSecurityTokenImpl token = createToken((long)Integer.MAX_VALUE, securityContext);
+
+        assertTimeoutPreemptively(Duration.ofSeconds(60), () -> {
+            WSSecurityException exception =
+                assertThrows(WSSecurityException.class, token::generateDerivedKey);
+            assertEquals(WSSecurityException.ErrorCode.INVALID_SECURITY_TOKEN,
+                         exception.getErrorCode());
+        });
+    }
+}