This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/policy-enforcer in repository https://gitbox.apache.org/repos/asf/ws-wss4j.git
commit 567fe52118750310b1a96590fca86d906bfb0e61 Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Mon Sep 21 07:50:02 2026 +0100 Stricter policy enforcement --- THREAT-MODEL.md | 31 ++++- .../wss4j/policy/stax/enforcer/PolicyEnforcer.java | 48 +++++++- .../stax/enforcer/PolicyEnforcerFactory.java | 82 ++++++++++++- .../policy/stax/test/VulnerabliltyVectorsTest.java | 129 +++++++++++++++++++++ .../test/resources/testdata/wsdl/rpcOperation.wsdl | 64 ++++++++++ 5 files changed, 348 insertions(+), 6 deletions(-) diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index c7884d411..8325d177f 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -128,6 +128,12 @@ A finding is in-model only if it reaches a row marked **yes**. spoofing should be left to the SOAP stack" — *(documented: `ws-security-policy-stax/src/test/java/org/apache/wss4j/policy/stax/test/VulnerabliltyVectorsTest.java`*) are out of model. → `OUT-OF-MODEL: adversary-not-in-scope`. + WSS4J does, however, refuse to enforce a policy that does not belong + to the operation named by the Body element (§8 P11). That is a + consistency check on its own policy selection, not SOAPAction + enforcement: it does not tell the stack which operation to dispatch, + it only declines to apply one operation's policy to another's + message. 2. **A SOAP parser.** WSS4J does not parse SOAP from bytes — it receives a `Document` (DOM engine) or `XMLStreamReader` (StAX engine) from the caller. XXE / DTD / billion-laughs defenses on the *XML-bytes-to-DOM* @@ -544,6 +550,29 @@ on each is captured in §14 Q10–Q11. - *(documented: `ws-security-stax/src/test/java/.../VulnerabliltyVectorsDecompressedBytesTest.java`)* +### P11 — The enforced policy belongs to the operation the message invokes (StAX policy mode) + +- **Condition**: streaming engine with `PolicyInputProcessor` / + `PolicyEnforcer`; the operation policies were built from a WSDL by + `PolicyEnforcerFactory`, or supplied by the integrator under the QName + of the SOAP Body child element. +- **Violation symptom**: a message whose Body element names operation A + is checked against the policy of operation B — by naming B in the + SOAPAction header, or by sending A's local name in a namespace the + WSDL does not declare — so the weaker of two policies is enforced for + an operation the SOAP stack dispatches under the stronger one. +- **Mechanism**: a policy is selected by the exact QName of the Body + child element, and a policy preselected by SOAPAction must agree with + it. A policy operation name that carries no namespace can only be + compared on its local name; that comparison is accepted only while the + local name belongs to exactly one operation, and the message is + rejected otherwise. +- **Severity**: **security-critical** where two operations of the same + local name carry different policies; `VALID-HARDENING` otherwise, + since the SOAP stack dispatches on the full QName (§3 item 1). +- *(documented: + `ws-security-policy-stax/src/test/java/.../VulnerabliltyVectorsTest.java`)* + ## §9 Security properties the project does *not* provide State each plainly so a triager can route an inbound report to the @@ -1065,7 +1094,7 @@ documented sources are the AsciiDoc pages under `src/site/asciidoc/` | `ws-security-stax/src/main/java/org/apache/wss4j/stax/setup/WSSec.java` | `SchemaFactory.setFeature(FEATURE_SECURE_PROCESSING, true)` for bundled schema load | §5 environment | | `ws-security-common/src/main/java/org/apache/wss4j/common/cache/EHCacheReplayCache.java` | EHCache-backed replay defense | §5a, §8 P5, §10 item 8 | | `ws-security-common/src/main/java/org/apache/wss4j/common/ConfigurationConstants.java` + `ws-security-dom/.../RequestData.java` | configuration-tag definitions and defaults | §5a | -| `ws-security-policy-stax/src/test/java/.../VulnerabliltyVectorsTest.java` | SOAPAction spoofing is out of scope; signed-body-relocation is a `VALID` regression test | §3 item 1, §8 P2 | +| `ws-security-policy-stax/src/test/java/.../VulnerabliltyVectorsTest.java` | SOAPAction spoofing is out of scope; signed-body-relocation is a `VALID` regression test; the policy selected for an operation must belong to it | §3 item 1, §8 P2, §8 P11 | | `ws-security-stax/src/test/java/.../VulnerabliltyVectorsDecompressedBytesTest.java` | "Maximum byte count … reached" enforced on signed compressed payloads | §8 P10, §10 item 8 | | `ChangeLog.txt` WSS-677 (2.3.1) | "Comparison in validate class is vulnerable to timing side channels" | §8 P8 | | `ChangeLog.txt` WSS-694 (3.0.0) | Move wss4j to native jakarta namespace | §5 environment | diff --git a/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcer.java b/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcer.java index 97b6c8c5e..db1322d96 100644 --- a/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcer.java +++ b/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcer.java @@ -204,8 +204,7 @@ public class PolicyEnforcer implements SecurityEventListener { if (operationName != null) { if (soapOperationName.equals(operationName)) { return operationPolicy; - } else if ((operationName.getNamespaceURI() == null || operationName.getNamespaceURI().length() == 0) - && soapOperationName.getLocalPart().equals(operationName.getLocalPart())) { + } else if (matchesOnLocalPartAlone(operationName, soapOperationName)) { noNamespaceOperation = operationPolicy; } } @@ -221,8 +220,49 @@ public class PolicyEnforcer implements SecurityEventListener { if (policyOperationName.equals(soapOperationName)) { return true; } - return (policyOperationName.getNamespaceURI() == null || policyOperationName.getNamespaceURI().length() == 0) - && soapOperationName.getLocalPart().equals(policyOperationName.getLocalPart()); + return matchesOnLocalPartAlone(policyOperationName, soapOperationName); + } + + /** + * A policy operation name that carries no namespace can only be compared against the local + * part of the Body element - the namespace it should have is simply not known. That + * comparison is a last resort for operations whose QName could not be determined, and it + * accepts a Body element in ANY namespace. + * + * It is only defensible while that local part still identifies one operation. As soon as a + * second operation shares it, matching on the local part alone no longer says which of the + * two the SOAP stack will dispatch to, and an attacker can pick whichever of the two + * policies is the weaker by varying nothing but the namespace of the Body element - or, on + * the SOAPAction path, by naming the weaker operation in the SOAPAction header while the + * Body dispatches to the stronger one. Refuse the loose match in that case, so that the + * caller fails closed instead of enforcing a policy that belongs to another operation. + */ + private boolean matchesOnLocalPartAlone(QName policyOperationName, QName soapOperationName) { + String policyNamespace = policyOperationName.getNamespaceURI(); + if (policyNamespace != null && policyNamespace.length() > 0) { + return false; + } + String localPart = policyOperationName.getLocalPart(); + if (!soapOperationName.getLocalPart().equals(localPart)) { + return false; + } + if (countOperationsNamed(localPart) > 1) { + LOG.warn("More than one operation is named {}; refusing to match the policy for it " + + "against {} on the local name alone", localPart, soapOperationName); + return false; + } + return true; + } + + private int countOperationsNamed(String localPart) { + int count = 0; + for (OperationPolicy operationPolicy : operationPolicies) { + QName operationName = operationPolicy.getOperationName(); + if (operationName != null && localPart.equals(operationName.getLocalPart())) { + count++; + } + } + return count; } /** diff --git a/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcerFactory.java b/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcerFactory.java index d25bbfde3..7f32cfcae 100644 --- a/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcerFactory.java +++ b/ws-security-policy-stax/src/main/java/org/apache/wss4j/policy/stax/enforcer/PolicyEnforcerFactory.java @@ -26,6 +26,7 @@ import java.util.List; import java.util.Map; import javax.wsdl.Binding; +import javax.wsdl.BindingInput; import javax.wsdl.BindingOperation; import javax.wsdl.Definition; import javax.wsdl.Message; @@ -38,7 +39,11 @@ import javax.wsdl.WSDLElement; import javax.wsdl.WSDLException; import javax.wsdl.extensions.ExtensibilityElement; import javax.wsdl.extensions.UnknownExtensibilityElement; +import javax.wsdl.extensions.soap.SOAPBinding; +import javax.wsdl.extensions.soap.SOAPBody; import javax.wsdl.extensions.soap.SOAPOperation; +import javax.wsdl.extensions.soap12.SOAP12Binding; +import javax.wsdl.extensions.soap12.SOAP12Body; import javax.wsdl.extensions.soap12.SOAP12Operation; import javax.wsdl.factory.WSDLFactory; import javax.wsdl.xml.WSDLReader; @@ -258,7 +263,15 @@ public class PolicyEnforcerFactory { } } if (operationName == null) { - operationName = new QName(null, operation.getName()); + //For rpc style the Body child element is the operation name, qualified + //with the namespace given on the soap:body of the binding input, so the + //QName that will be reported in the OperationSecurityEvent is knowable + //here too. Registering it keeps the operation out of the local-name-only + //fallback in PolicyEnforcer, which matches a Body element in ANY + //namespace. Where the namespace is not given we are no worse off than + //before and still register the bare operation name. + operationName = new QName(rpcStyleBodyNamespace(binding, bindingOperation), + operation.getName()); } OperationPolicy operationPolicy = new OperationPolicy(operationName); operationPolicyList.add(operationPolicy); @@ -288,6 +301,73 @@ public class PolicyEnforcerFactory { return operationPolicyList; } + /** + * Returns the namespace that the SOAP Body child element carries for an rpc style + * operation - the namespace attribute of its soap:body - or null when the operation is + * not rpc style or when no namespace is given. + */ + private String rpcStyleBodyNamespace(Binding binding, BindingOperation bindingOperation) { + if (!isRpcStyle(binding, bindingOperation)) { + return null; + } + BindingInput bindingInput = bindingOperation.getBindingInput(); + if (bindingInput == null) { + return null; + } + List<?> extensibilityElements = bindingInput.getExtensibilityElements(); + if (extensibilityElements == null) { + return null; + } + for (int i = 0; i < extensibilityElements.size(); i++) { + Object extensibilityElement = extensibilityElements.get(i); + String namespace = null; + if (extensibilityElement instanceof SOAPBody) { + namespace = ((SOAPBody) extensibilityElement).getNamespaceURI(); + } else if (extensibilityElement instanceof SOAP12Body) { + namespace = ((SOAP12Body) extensibilityElement).getNamespaceURI(); + } + if (namespace != null && namespace.length() > 0) { + return namespace; + } + } + return null; + } + + /** + * The style is given either on the binding operation itself or, failing that, on the + * binding. The default in WSDL 1.1 is "document". + */ + private boolean isRpcStyle(Binding binding, BindingOperation bindingOperation) { + String style = findStyle(bindingOperation.getExtensibilityElements()); + if (style == null) { + style = findStyle(binding.getExtensibilityElements()); + } + return "rpc".equals(style); + } + + private String findStyle(List<?> extensibilityElements) { + if (extensibilityElements == null) { + return null; + } + for (int i = 0; i < extensibilityElements.size(); i++) { + Object extensibilityElement = extensibilityElements.get(i); + String style = null; + if (extensibilityElement instanceof SOAPOperation) { + style = ((SOAPOperation) extensibilityElement).getStyle(); + } else if (extensibilityElement instanceof SOAP12Operation) { + style = ((SOAP12Operation) extensibilityElement).getStyle(); + } else if (extensibilityElement instanceof SOAPBinding) { + style = ((SOAPBinding) extensibilityElement).getStyle(); + } else if (extensibilityElement instanceof SOAP12Binding) { + style = ((SOAP12Binding) extensibilityElement).getStyle(); + } + if (style != null && style.length() > 0) { + return style; + } + } + return null; + } + private Policy getPolicy(Service service, Port port, Binding binding, BindingOperation bindingOperation, Operation operation) throws WSSPolicyException { List<Policy> policies = new ArrayList<>(); diff --git a/ws-security-policy-stax/src/test/java/org/apache/wss4j/policy/stax/test/VulnerabliltyVectorsTest.java b/ws-security-policy-stax/src/test/java/org/apache/wss4j/policy/stax/test/VulnerabliltyVectorsTest.java index 8f83f24d6..7a6ba07b8 100644 --- a/ws-security-policy-stax/src/test/java/org/apache/wss4j/policy/stax/test/VulnerabliltyVectorsTest.java +++ b/ws-security-policy-stax/src/test/java/org/apache/wss4j/policy/stax/test/VulnerabliltyVectorsTest.java @@ -32,8 +32,10 @@ import javax.xml.transform.stream.StreamResult; import javax.xml.xpath.XPathConstants; import javax.xml.xpath.XPathExpression; +import org.apache.neethi.Policy; import org.apache.wss4j.common.ext.WSSecurityException; import org.apache.wss4j.dom.handler.WSHandlerConstants; +import org.apache.wss4j.policy.stax.OperationPolicy; import org.apache.wss4j.policy.stax.enforcer.PolicyEnforcer; import org.apache.wss4j.policy.stax.enforcer.PolicyEnforcerFactory; import org.apache.wss4j.policy.stax.enforcer.PolicyInputProcessor; @@ -124,6 +126,133 @@ public class VulnerabliltyVectorsTest extends AbstractTestBase { assertEquals(WSSecurityException.INVALID_SECURITY, ex.getFaultCode()); } + /** + * An operation whose policy is registered without a namespace is matched on its local name + * alone, which accepts a Body element in any namespace. That is only tenable while the + * local name identifies one operation: once a second operation shares it, the local name no + * longer says which of the two the SOAP stack will dispatch to, and the policy of the one + * could be enforced for a message that invokes the other. Fail closed instead. + */ + @Test + public void testAmbiguousNoNamespaceOperationNameDoesNotMatchAnyNamespace() throws Exception { + List<OperationPolicy> operationPolicies = + List.of(operationPolicy(new QName(null, "getBalance"), null), + operationPolicy(new QName("http://www.example.net/secure", "getBalance"), null)); + + PolicyEnforcer policyEnforcer = + new PolicyEnforcer(operationPolicies, "", false, null, 0, null, false); + + OperationSecurityEvent operationSecurityEvent = new OperationSecurityEvent(); + operationSecurityEvent.setOperation(new QName("http://www.example.net/other", "getBalance")); + + WSSecurityException ex = assertThrows(WSSecurityException.class, + () -> policyEnforcer.registerSecurityEvent(operationSecurityEvent)); + + assertEquals(WSSecurityException.INVALID_SECURITY, ex.getFaultCode()); + } + + /** + * The same ambiguity on the SOAPAction path: the SOAPAction header selects the policy of the + * operation registered without a namespace, while the Body element dispatches to the other + * operation of that name. The cross-check added for SOAPAction spoofing must reject this. + */ + @Test + public void testAmbiguousNoNamespaceOperationNameDoesNotSatisfyTheSOAPActionCheck() throws Exception { + List<OperationPolicy> operationPolicies = + List.of(operationPolicy(new QName(null, "getBalance"), "urn:getBalanceUnsecured"), + operationPolicy(new QName("http://www.example.net/secure", "getBalance"), "urn:getBalance")); + + PolicyEnforcer policyEnforcer = + new PolicyEnforcer(operationPolicies, "urn:getBalanceUnsecured", false, null, 0, null, false); + + OperationSecurityEvent operationSecurityEvent = new OperationSecurityEvent(); + operationSecurityEvent.setOperation(new QName("http://www.example.net/secure", "getBalance")); + + WSSecurityException ex = assertThrows(WSSecurityException.class, + () -> policyEnforcer.registerSecurityEvent(operationSecurityEvent)); + + assertEquals("SOAPAction (urn:getBalanceUnsecured) does not match with the current Operation: " + + "{http://www.example.net/secure}getBalance", + ex.getCause().getMessage()); + assertEquals(WSSecurityException.INVALID_SECURITY, ex.getFaultCode()); + } + + /** + * An operation name that carries a namespace is matched exactly, so the policy of the + * namespace-less operation of the same name is not selected for it. + */ + @Test + public void testNamespacedOperationNameSelectsItsOwnPolicy() throws Exception { + List<OperationPolicy> operationPolicies = + List.of(operationPolicy(new QName(null, "getBalance"), null), + operationPolicy(new QName("http://www.example.net/secure", "getBalance"), null)); + + PolicyEnforcer policyEnforcer = + new PolicyEnforcer(operationPolicies, "", false, null, 0, null, false); + + OperationSecurityEvent operationSecurityEvent = new OperationSecurityEvent(); + operationSecurityEvent.setOperation(new QName("http://www.example.net/secure", "getBalance")); + + policyEnforcer.registerSecurityEvent(operationSecurityEvent); + } + + /** + * Where the local name is unambiguous the fallback is kept, so an integrator that registers + * its operations without a namespace keeps working. + */ + @Test + public void testUnambiguousNoNamespaceOperationNameStillMatches() throws Exception { + List<OperationPolicy> operationPolicies = + List.of(operationPolicy(new QName(null, "getBalance"), null)); + + PolicyEnforcer policyEnforcer = + new PolicyEnforcer(operationPolicies, "", false, null, 0, null, false); + + OperationSecurityEvent operationSecurityEvent = new OperationSecurityEvent(); + operationSecurityEvent.setOperation(new QName("http://www.example.net/other", "getBalance")); + + policyEnforcer.registerSecurityEvent(operationSecurityEvent); + } + + /** + * An rpc style operation is registered under the QName that will actually appear as the Body + * child element - the operation name qualified with the namespace from soap:body - and so is + * matched exactly rather than on its local name alone. + */ + @Test + public void testRpcOperationIsBoundToItsSOAPBodyNamespace() throws Exception { + PolicyEnforcerFactory policyEnforcerFactory = PolicyEnforcerFactory.newInstance( + this.getClass().getClassLoader().getResource("testdata/wsdl/rpcOperation.wsdl")); + PolicyEnforcer policyEnforcer = policyEnforcerFactory.newPolicyEnforcer("", false, null, 0, false); + + OperationSecurityEvent operationSecurityEvent = new OperationSecurityEvent(); + operationSecurityEvent.setOperation(new QName("http://www.example.net/rpc", "getBalance")); + + policyEnforcer.registerSecurityEvent(operationSecurityEvent); + } + + @Test + public void testRpcOperationInAnotherNamespaceIsRejected() throws Exception { + PolicyEnforcerFactory policyEnforcerFactory = PolicyEnforcerFactory.newInstance( + this.getClass().getClassLoader().getResource("testdata/wsdl/rpcOperation.wsdl")); + PolicyEnforcer policyEnforcer = policyEnforcerFactory.newPolicyEnforcer("", false, null, 0, false); + + OperationSecurityEvent operationSecurityEvent = new OperationSecurityEvent(); + operationSecurityEvent.setOperation(new QName("http://example.com/evil", "getBalance")); + + WSSecurityException ex = assertThrows(WSSecurityException.class, + () -> policyEnforcer.registerSecurityEvent(operationSecurityEvent)); + + assertEquals(WSSecurityException.INVALID_SECURITY, ex.getFaultCode()); + } + + private static OperationPolicy operationPolicy(QName operationName, String soapAction) { + OperationPolicy operationPolicy = new OperationPolicy(operationName); + operationPolicy.setPolicy(new Policy().normalize(true)); + operationPolicy.setOperationAction(soapAction); + return operationPolicy; + } + @Test public void testSignedBodyRelocationToHeader() throws Exception { InputStream sourceDocument = this.getClass().getClassLoader().getResourceAsStream("testdata/plain-soap-1.1.xml"); diff --git a/ws-security-policy-stax/src/test/resources/testdata/wsdl/rpcOperation.wsdl b/ws-security-policy-stax/src/test/resources/testdata/wsdl/rpcOperation.wsdl new file mode 100644 index 000000000..d8f178a52 --- /dev/null +++ b/ws-security-policy-stax/src/test/resources/testdata/wsdl/rpcOperation.wsdl @@ -0,0 +1,64 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + 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. +--> +<wsdl:definitions + name="RpcService" + targetNamespace="http://www.example.net/RpcService" + xmlns:tns="http://www.example.net/RpcService" + xmlns:xs="http://www.w3.org/2001/XMLSchema" + xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" + xmlns:wsdl="http://schemas.xmlsoap.org/wsdl/" + > + + <!-- An rpc style operation: the Body child element is the operation name qualified + with the namespace given below on soap:body, not a schema element. --> + + <wsdl:message name="getBalanceRequest"> + <wsdl:part name="account" type="xs:string"/> + </wsdl:message> + <wsdl:message name="getBalanceResponse"> + <wsdl:part name="balance" type="xs:string"/> + </wsdl:message> + + <wsdl:portType name="RpcPort"> + <wsdl:operation name="getBalance"> + <wsdl:input message="tns:getBalanceRequest"/> + <wsdl:output message="tns:getBalanceResponse"/> + </wsdl:operation> + </wsdl:portType> + + <wsdl:binding name="RpcSOAPBinding" type="tns:RpcPort"> + <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="rpc"/> + <wsdl:operation name="getBalance"> + <soap:operation soapAction="getBalance" style="rpc"/> + <wsdl:input> + <soap:body use="literal" namespace="http://www.example.net/rpc"/> + </wsdl:input> + <wsdl:output> + <soap:body use="literal" namespace="http://www.example.net/rpc"/> + </wsdl:output> + </wsdl:operation> + </wsdl:binding> + + <wsdl:service name="RpcService"> + <wsdl:port name="Rpc" binding="tns:RpcSOAPBinding"> + <soap:address location="http://localhost:8080/RpcService"/> + </wsdl:port> + </wsdl:service> +</wsdl:definitions>
