This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/ut-max-iterations in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
commit 213bb24897e2bb94eba1d49a66c7f1aa0dbdd4b0 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Fri Sep 18 11:31:29 2026 +0100 Putting max on UsernameToken stax iterations --- .../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()); + }); + } +}
