diff --git a/README.txt b/README.txt index 7e732b7..85b2335 100644 --- a/README.txt +++ b/README.txt @@ -52,3 +52,12 @@ Policy normalization also enforces several hard caps: MAX_ALTERNATIVES can still materialize hundreds of millions of references (alternatives × parent widths). This cap ensures a fast RuntimeException instead of OutOfMemoryError. + +`PolicyComparator` also enforces a comparison budget: + +- `MAX_COMPARISONS` - maximum number of pairwise component comparisons a + single top-level `compare(...)` call may perform. + Default: `10000000`. `PolicyComparator`'s list matching is unordered and + unmemoized, so mismatched operand orderings cost O(n1 * n2) comparisons. + This cap turns an engineered quadratic comparison into a fast, predictable + RuntimeException instead of pinned CPU. diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 8225d56..9e29d45 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -432,6 +432,22 @@ overload disables both. The pre-parsed-`Element` / `XMLStreamReader` / divergence is security-meaningful downstream. - *(inferred — §14 Q10)* +### P12 — `PolicyComparator` pairwise-comparison budget is enforced + +- **Condition**: `PolicyComparator.compare(...)` (directly, or + transitively via `Policy.equals` / `AbstractPolicyOperator.equal` / + `PolicyContainingPrimitiveAssertion.equal`) is invoked on two + well-formed `PolicyComponent` trees. +- **Violation symptom**: the unordered, unmemoized list-matching in + `compare(List, List)` completes more than `MAX_COMPARISONS` pairwise + component comparisons without rejection — an adversarial operand + ordering costs O(n1 * n2) comparisons with no cap, up to minutes of + pinned CPU at the parser budgets. +- **Severity**: **availability-relevant** (quadratic, not exponential — + bounded even unpatched), `VALID` per §13. +- *(documented: `README.txt` — `MAX_COMPARISONS`, default `10000000`; + `src/main/java/org/apache/neethi/util/PolicyComparator.java`)* + ## §9 Security properties the project does *not* provide State each plainly so a triager can route an inbound report to the @@ -796,6 +812,7 @@ source comments. The project website is | --- | --- | --- | | `README.txt` | "implementation of WS-Policy Specification (September, 2007)"; "It provides a convenient model and an API to process policy information at runtime and an extension model for serialization and de-serialization of domain-specific Assertions" | §1, §2 intended use | | `README.txt` | documented security budgets: `org.apache.neethi.parser.maxDepth=256`, `org.apache.neethi.parser.maxElements=100000`, `org.apache.neethi.parser.maxAttributes=10000`, `org.apache.neethi.remote.maxPolicyBytes=67108864`, and normalization/intersection cap `10000` alternatives; invalid/unset values fall back to defaults | §5a, §6, §8 P2-P6, §10 item 4 | +| `README.txt` | `PolicyComparator` comparison budget: `MAX_COMPARISONS=10000000` pairwise component comparisons per top-level `compare(...)` call; throws `RuntimeException` on exhaustion | §8 P12 | | `src/main/java/org/apache/neethi/PolicyBuilder.java` lines 99-100 (`getPolicy(InputStream)`) | `xif.setProperty(IS_SUPPORTING_EXTERNAL_ENTITIES, FALSE); xif.setProperty(SUPPORT_DTD, FALSE)` | §8 P1, §11a | | `PolicyBuilder.java` lines 140-141 (`getPolicyReference(InputStream)`) | same XXE/DTD hardening on the PolicyReference parse path | §8 P1, §11a | | `PolicyReference.java` lines 141-190 (`getRemoteReferencedPolicy(String u)`) | the remote-policy fetcher | §1 (deployment shape), §4 B5, §5 network, §5a, §8 P7-P10, §9 first three bullets, §10 items 2-3, §11 | @@ -806,5 +823,5 @@ source comments. The project website is | `PolicyEngine.java` lines 45-52 | "static synchronized PolicyBuilder" facade | §9 false-friend, §11 | | `AssertionBuilderFactoryImpl.java`, `util.Service` | ServiceLoader-style discovery of `AssertionBuilder` via `META-INF/services/` | §5, §10 item 6 | | `Policy.java`, `All.java`, `ExactlyOne.java`, `AbstractPolicyOperator.java` | `normalize(reg, deep)` resolves references via registry/local `#id` only and throws on a miss; remote fetch requires a direct embedder call to `PolicyReference.normalize(reg, deep)` or `getRemoteReferencedPolicy(...)` | §4 B4-B5, §11 | -| `util.PolicyIntersector`, `util.PolicyComparator` | policy-algebra utilities | §8 P11, §14 Q10 | +| `util.PolicyIntersector`, `util.PolicyComparator` | policy-algebra utilities | §8 P11-P12, §14 Q10 | | `RELEASE-NOTE.txt` | release notes per version | §1 supported branches | diff --git a/src/main/java/org/apache/neethi/util/PolicyComparator.java b/src/main/java/org/apache/neethi/util/PolicyComparator.java index 0c173f6..4411b49 100644 --- a/src/main/java/org/apache/neethi/util/PolicyComparator.java +++ b/src/main/java/org/apache/neethi/util/PolicyComparator.java @@ -37,6 +37,25 @@ private PolicyComparator() { //utility class } + /** + * Maximum number of pairwise component comparisons a single top-level + * compare call may perform. The list comparison is unordered nested + * matching with no memoization, so mismatched operand orderings cost + * O(n1 * n2) comparisons, converting an engineered quadratic comparison + * into a fast, predictable RuntimeException instead of pinned CPU. + */ + private static final long MAX_COMPARISONS = 10_000_000L; + + private static void chargeComparison(long[] budget) { + if (++budget[0] > MAX_COMPARISONS) { + throw new RuntimeException( + "Policy comparison exceeded the maximum number of component" + + " comparisons (" + MAX_COMPARISONS + "). The operands may be" + + " crafted to cause Algorithmic Complexity DoS via unordered" + + " matching."); + } + } + /** * Returns {@code true} if the two policies have the same semantics * @@ -47,6 +66,10 @@ private PolicyComparator() { * @return {@code true} if both policies have the same semantics */ public static boolean compare(Policy arg1, Policy arg2) { + return compare(arg1, arg2, new long[1]); + } + + private static boolean compare(Policy arg1, Policy arg2, long[] budget) { // check Name attributes of each policies if (arg1.getName() != null) { @@ -79,7 +102,7 @@ public static boolean compare(Policy arg1, Policy arg2) { } } - return compare(arg1.getPolicyComponents(), arg2.getPolicyComponents()); + return compare(arg1.getPolicyComponents(), arg2.getPolicyComponents(), budget); } /** @@ -93,18 +116,23 @@ public static boolean compare(Policy arg1, Policy arg2) { * @return {@code true} if both PolicyComponents have the same semantics */ public static boolean compare(PolicyComponent arg1, PolicyComponent arg2) { + return compare(arg1, arg2, new long[1]); + } + + private static boolean compare(PolicyComponent arg1, PolicyComponent arg2, long[] budget) { + chargeComparison(budget); if (!arg1.getClass().equals(arg2.getClass())) { return false; } if (arg1 instanceof Policy) { - return compare((Policy) arg1, (Policy) arg2); + return compare((Policy) arg1, (Policy) arg2, budget); } else if (arg1 instanceof All) { - return compare((All) arg1, (All) arg2); + return compare((All) arg1, (All) arg2, budget); } else if (arg1 instanceof ExactlyOne) { - return compare((ExactlyOne) arg1, (ExactlyOne) arg2); + return compare((ExactlyOne) arg1, (ExactlyOne) arg2, budget); } else if (arg1 instanceof Assertion) { return compare((Assertion) arg1, (Assertion) arg2); @@ -117,11 +145,19 @@ public static boolean compare(PolicyComponent arg1, PolicyComponent arg2) { } public static boolean compare(All arg1, All arg2) { - return compare(arg1.getPolicyComponents(), arg2.getPolicyComponents()); + return compare(arg1, arg2, new long[1]); + } + + private static boolean compare(All arg1, All arg2, long[] budget) { + return compare(arg1.getPolicyComponents(), arg2.getPolicyComponents(), budget); } public static boolean compare(ExactlyOne arg1, ExactlyOne arg2) { - return compare(arg1.getPolicyComponents(), arg2.getPolicyComponents()); + return compare(arg1, arg2, new long[1]); + } + + private static boolean compare(ExactlyOne arg1, ExactlyOne arg2, long[] budget) { + return compare(arg1.getPolicyComponents(), arg2.getPolicyComponents(), budget); } public static boolean compare(Assertion arg1, Assertion arg2) { @@ -131,7 +167,8 @@ public static boolean compare(Assertion arg1, Assertion arg2) { return true; } - private static boolean compare(List arg1, List arg2) { + private static boolean compare(List arg1, List arg2, + long[] budget) { if (arg1.size() != arg2.size()) { return false; } @@ -140,7 +177,7 @@ private static boolean compare(List arg1, List for (PolicyComponent assertion1 : arg1) { boolean match = false; for (PolicyComponent assertion2 : arg2) { - if (compare(assertion1, assertion2)) { + if (compare(assertion1, assertion2, budget)) { match = true; break; } diff --git a/src/test/java/org/apache/neethi/util/PolicyComparatorDoSTest.java b/src/test/java/org/apache/neethi/util/PolicyComparatorDoSTest.java new file mode 100644 index 0000000..aa07730 --- /dev/null +++ b/src/test/java/org/apache/neethi/util/PolicyComparatorDoSTest.java @@ -0,0 +1,83 @@ +/** + * 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.neethi.util; + +import javax.xml.namespace.QName; + +import org.apache.neethi.All; +import org.apache.neethi.ExactlyOne; +import org.apache.neethi.Policy; +import org.apache.neethi.PolicyTestCase; +import org.apache.neethi.builders.PrimitiveAssertion; + +import org.junit.Test; + +/** + * PolicyComparator's list comparison is unordered nested matching with no + * memoization: each component of one list scans the other list for a match, + * so operand orderings engineered to match late cost O(n1 * n2) comparisons + * — minutes of pinned CPU at the parse budgets. The comparison budget must + * turn that into a fast, predictable RuntimeException. + */ +public class PolicyComparatorDoSTest extends PolicyTestCase { + + private static final int LARGE = 6000; + private static final int SMALL = 200; + + @Test + public void testQuadraticUnorderedMatchingIsRejectedByComparisonBudget() { + Policy p1 = buildAlternativesPolicy(LARGE, false); + Policy p2 = buildAlternativesPolicy(LARGE, true); + + try { + PolicyComparator.compare(p1, p2); + fail("Expected RuntimeException due to comparison budget"); + } catch (RuntimeException ex) { + assertTrue(ex.getMessage().contains("component comparisons")); + } + } + + @Test + public void testModerateUnorderedComparisonStillWorks() { + Policy p1 = buildAlternativesPolicy(SMALL, false); + Policy p2 = buildAlternativesPolicy(SMALL, true); + + assertTrue(PolicyComparator.compare(p1, p2)); + } + + /** + * Builds a Policy holding one ExactlyOne of {@code n} single-assertion + * alternatives. With {@code reversed} set, the alternatives appear in + * reverse order, so the unordered matcher finds each partner only at the + * far end of its scan — the quadratic worst case for equal operands. + */ + private static Policy buildAlternativesPolicy(int n, boolean reversed) { + Policy policy = new Policy(); + ExactlyOne eo = new ExactlyOne(); + for (int i = 0; i < n; i++) { + int idx = reversed ? n - 1 - i : i; + All all = new All(); + all.addPolicyComponent(new PrimitiveAssertion(new QName("urn:test", "a" + idx))); + eo.addPolicyComponent(all); + } + policy.addPolicyComponent(eo); + return policy; + } +}