Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.txt
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,15 @@ Policy normalization also enforces several hard caps:
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.

`PolicyIntersector` also enforces an intersection work budget:

- `MAX_INTERSECT_STEPS` - maximum number of assertion-pair intersection
attempts a single top-level `intersect(...)` or `compatiblePolicies(...)`
call may perform, including recursion into nested policies of
`PolicyContainingAssertion` values.
Default: `1000000`. The alternatives-output cap bounds what is emitted, but
not all candidate-search work; recursive same-QName fan-out can otherwise
trigger algorithmic-complexity DoS through exponential search effort.
This cap turns that engineered search into a fast, predictable
RuntimeException instead of pinned CPU.
22 changes: 20 additions & 2 deletions THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ feature toggles**. The runtime security envelope is shaped by:
| `org.apache.neethi.parser.maxAttributes` | `10000` *(documented: `README.txt`)* | hardened-by-default | bounds maximum number of parsed attributes |
| `org.apache.neethi.remote.maxPolicyBytes` | `67108864` bytes (`64 MiB`) *(documented: `README.txt`)* | hardened-by-default | bounds bytes read for remotely dereferenced policies |
| Policy normalization alternatives cap | `10000` alternatives *(documented: `README.txt`)* | hardened-by-default | blocks exponential policy-alternative expansion during normalization/intersection |
| `PolicyIntersector` work budget (`MAX_INTERSECT_STEPS`) | `1000000` assertion-pair intersection attempts per top-level `intersect(...)` / `compatiblePolicies(...)` call *(documented: `README.txt`)* | hardened-by-default | bounds recursive candidate-search work in intersection (including nested `PolicyContainingAssertion` recursion) |
| `META-INF/services/.../AssertionBuilder` | classpath-discovered | classpath is the trust gate | adds domain-specific assertion vocabularies |

### The insecure-default case
Expand Down Expand Up @@ -289,6 +290,10 @@ overload disables both. The pre-parsed-`Element` / `XMLStreamReader` /
*(documented: `README.txt`)*.
- Documented hard cap of `10000` normalized policy alternatives during
normalization/intersection *(documented: `README.txt`)*.
- Documented hard cap of `1000000` assertion-pair intersection attempts
per top-level `PolicyIntersector.intersect(...)` /
`PolicyIntersector.compatiblePolicies(...)` call *(documented:
`README.txt`)*.
- No documented bound on the number of `PolicyReference` URIs in a
single policy *(inferred — §14 Q7)*. Transitive fetch chains cannot
occur inside one normalization call: a fetched policy is
Expand Down Expand Up @@ -448,6 +453,19 @@ overload disables both. The pre-parsed-`Element` / `XMLStreamReader` /
- *(documented: `README.txt` — `MAX_COMPARISONS`, default `10000000`;
`src/main/java/org/apache/neethi/util/PolicyComparator.java`)*

### P13 — `PolicyIntersector` recursive candidate-search work budget is enforced

- **Condition**: `PolicyIntersector.intersect(...)` and/or
`PolicyIntersector.compatiblePolicies(...)` is invoked on well-formed
policy trees.
- **Violation symptom**: a top-level call performs more than
`MAX_INTERSECT_STEPS` assertion-pair intersection attempts (including
recursion into nested `PolicyContainingAssertion` policies) without
rejection.
- **Severity**: **availability-relevant**, `VALID` per §13.
- *(documented: `README.txt` — `MAX_INTERSECT_STEPS`, default
`1000000`)*

## §9 Security properties the project does *not* provide

State each plainly so a triager can route an inbound report to the
Expand Down Expand Up @@ -811,7 +829,7 @@ source comments. The project website is
| Source | Claim | Lands in |
| --- | --- | --- |
| `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` | 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`, normalization/intersection output cap `10000` alternatives, and `PolicyIntersector` work budget `MAX_INTERSECT_STEPS=1000000`; invalid/unset values fall back to defaults | §5a, §6, §8 P2-P6/P13, §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 |
Expand All @@ -823,5 +841,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-P12, §14 Q10 |
| `util.PolicyIntersector`, `util.PolicyComparator` | policy-algebra utilities | §8 P11-P13, §14 Q10 |
| `RELEASE-NOTE.txt` | release notes per version | §1 supported branches |
83 changes: 71 additions & 12 deletions src/main/java/org/apache/neethi/util/PolicyIntersector.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,33 @@
* See Section 4.5 in http://www.w3.org/TR/2006/WD-ws-policy-20061117.
*/
public class PolicyIntersector {

/**
* Maximum number of assertion-pair intersection attempts a single
* top-level intersect/compatiblePolicies call may perform, including the
* recursion into nested policies of PolicyContainingAssertions. The
* output-alternative cap bounds what is emitted but not the search work,
* which otherwise grows with the product of the alternative sizes at
* every nesting level. The budget converts an engineered exponential
* search into a fast, predictable RuntimeException.
*/
private static final long MAX_INTERSECT_STEPS = 1_000_000L;

/** Work accounting shared across one top-level intersection. */
private static final class WorkBudget {
private long steps;

void step() {
steps++;
if (steps > MAX_INTERSECT_STEPS) {
throw new RuntimeException(
"Policy intersection exceeded the maximum number of assertion"
+ " intersection steps (" + MAX_INTERSECT_STEPS + "). The policies"
+ " may be crafted to cause Algorithmic Complexity DoS via"
+ " recursive candidate search.");
}
}
}

private boolean strict;

Expand All @@ -58,7 +85,8 @@ public void setStrict(boolean s) {
strict = s;
}

private Assertion intersect(Assertion a1, Assertion a2) {
private Assertion intersect(Assertion a1, Assertion a2, WorkBudget budget) {
budget.step();
if (a1 instanceof IntersectableAssertion) {
if (!((IntersectableAssertion)a1).isCompatible(a2, strict)) {
return null;
Expand All @@ -74,8 +102,7 @@ private Assertion intersect(Assertion a1, Assertion a2) {
PolicyContainingAssertion pc2 = (PolicyContainingAssertion)a2;
Policy p1 = pc1.getPolicy();
Policy p2 = pc2.getPolicy();
PolicyIntersector pi = new PolicyIntersector(strict);
if (pi.compatiblePolicies(p1, p2)) {
if (compatiblePolicies(p1, p2, budget)) {
return a1;
}
} else {
Expand All @@ -86,12 +113,13 @@ private Assertion intersect(Assertion a1, Assertion a2) {
}
private Assertion findCompatibleAssertion(Assertion assertion,
Collection<? extends PolicyComponent> alt,
boolean remove) {
boolean remove,
WorkBudget budget) {
Iterator<? extends PolicyComponent> iterator = alt.iterator();
while (iterator.hasNext()) {
PolicyComponent a = iterator.next();
if (a instanceof Assertion) {
Assertion compatible = intersect(assertion, (Assertion)a);
Assertion compatible = intersect(assertion, (Assertion)a, budget);
if (null != compatible) {
if (remove) {
iterator.remove();
Expand All @@ -106,11 +134,17 @@ private Assertion findCompatibleAssertion(Assertion assertion,

boolean compatibleAlternatives(Collection<? extends PolicyComponent> alt1,
Collection<? extends PolicyComponent> alt2) {
return compatibleAlternatives(alt1, alt2, new WorkBudget());
}

private boolean compatibleAlternatives(Collection<? extends PolicyComponent> alt1,
Collection<? extends PolicyComponent> alt2,
WorkBudget budget) {
if (alt1.isEmpty() && alt2.isEmpty()) {
return true;
}

All all = createCompatibleAlternatives(alt1, alt2, true);
All all = createCompatibleAlternatives(alt1, alt2, true, budget);
if (all == null) {
return false;
}
Expand All @@ -120,6 +154,13 @@ boolean compatibleAlternatives(Collection<? extends PolicyComponent> alt1,
All createCompatibleAlternatives(Collection<? extends PolicyComponent> alt1,
Collection<? extends PolicyComponent> alt2,
boolean remove) {
return createCompatibleAlternatives(alt1, alt2, remove, new WorkBudget());
}

private All createCompatibleAlternatives(Collection<? extends PolicyComponent> alt1,
Collection<? extends PolicyComponent> alt2,
boolean remove,
WorkBudget budget) {
All all = new All();
if (alt1.isEmpty() && alt2.isEmpty()) {
return all;
Expand All @@ -132,7 +173,7 @@ All createCompatibleAlternatives(Collection<? extends PolicyComponent> alt1,
while (iterator.hasNext()) {
PolicyComponent a1 = iterator.next();
if (a1 instanceof Assertion) {
Assertion assertion = findCompatibleAssertion((Assertion)a1, alt2, remove);
Assertion assertion = findCompatibleAssertion((Assertion)a1, alt2, remove, budget);
if (assertion != null) {
if (remove) {
iterator.remove();
Expand All @@ -149,7 +190,7 @@ All createCompatibleAlternatives(Collection<? extends PolicyComponent> alt1,
while (iterator.hasNext()) {
PolicyComponent a2 = iterator.next();
if (a2 instanceof Assertion) {
Assertion assertion = findCompatibleAssertion((Assertion)a2, alt1, remove);
Assertion assertion = findCompatibleAssertion((Assertion)a2, alt1, remove, budget);
if (assertion != null) {
all.addPolicyComponent(assertion);
} else if (!strict && ((Assertion)a2).isIgnorable()) {
Expand All @@ -163,16 +204,23 @@ All createCompatibleAlternatives(Collection<? extends PolicyComponent> alt1,
}

public boolean compatiblePolicies(Policy p1, Policy p2) {
return compatiblePolicies(p1, p2, new WorkBudget());
}

private boolean compatiblePolicies(Policy p1, Policy p2, WorkBudget budget) {
// normalize p2 exactly once: Policy.getAlternatives() re-runs a full
// normalization on every call, so it must not sit inside the p1 loop
List<List<Assertion>> alternatives2 = materializeAlternatives(p2);
Iterator<List<Assertion>> i1 = p1.getAlternatives();
while (i1.hasNext()) {
List<Assertion> alt1 = i1.next();
Iterator<List<Assertion>> i2 = p2.getAlternatives();
Iterator<List<Assertion>> i2 = alternatives2.iterator();
if (!i2.hasNext() && alt1.isEmpty()) {
return true;
}
while (i2.hasNext()) {
List<Assertion> alt2 = i2.next();
if (compatibleAlternatives(alt1, alt2)) {
if (compatibleAlternatives(alt1, alt2, budget)) {
return true;
}
}
Expand All @@ -184,15 +232,18 @@ public Policy intersect(Policy p1, Policy p2) {
return intersect(p1, p2, false);
}
public Policy intersect(Policy p1, Policy p2, boolean allowDups) {
WorkBudget budget = new WorkBudget();
Policy compatible = new Policy(p1.getPolicyRegistry(), p1.getNamespace());
ExactlyOne eo = new ExactlyOne(compatible);
// normalize p2 exactly once (see compatiblePolicies)
List<List<Assertion>> alternatives2 = materializeAlternatives(p2);
Iterator<List<Assertion>> i1 = p1.getAlternatives();
while (i1.hasNext()) {
List<Assertion> alt1 = i1.next();
Iterator<List<Assertion>> i2 = p2.getAlternatives();
Iterator<List<Assertion>> i2 = alternatives2.iterator();
while (i2.hasNext()) {
List<Assertion> alt2 = i2.next();
All all = createCompatibleAlternatives(alt1, alt2, !allowDups);
All all = createCompatibleAlternatives(alt1, alt2, !allowDups, budget);
if (all != null) {
long nextSize = (long)eo.getPolicyComponents().size() + 1;
AbstractPolicyOperator.checkMaximumAlternativeCount(nextSize, "intersection");
Expand All @@ -203,5 +254,13 @@ public Policy intersect(Policy p1, Policy p2, boolean allowDups) {

return compatible;
}

private static List<List<Assertion>> materializeAlternatives(Policy policy) {
List<List<Assertion>> alternatives = new ArrayList<List<Assertion>>();
for (Iterator<List<Assertion>> it = policy.getAlternatives(); it.hasNext();) {
alternatives.add(it.next());
}
return alternatives;
}

}
44 changes: 44 additions & 0 deletions src/test/java/org/apache/neethi/PolicyIntersectionDoSTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,16 @@

import javax.xml.namespace.QName;

import org.apache.neethi.builders.PolicyContainingPrimitiveAssertion;
import org.apache.neethi.builders.PrimitiveAssertion;
import org.apache.neethi.util.PolicyIntersector;
import org.junit.Test;

public class PolicyIntersectionDoSTest extends PolicyTestCase {

private static final int ALTERNATIVES_PER_POLICY = 101;
private static final int SAFE_ALTERNATIVES_PER_POLICY = 100;
private static final int LARGE_CANDIDATE_SET = 1500;

@Test
public void testIntersectionRejectsCartesianProductBeyondAlternativeBudget() {
Expand Down Expand Up @@ -68,6 +71,19 @@ public void testIntersectionWithNoCompatibleAlternativesReturnsEmptyPolicy() {
assertTrue(((ExactlyOne)intersection.getFirstPolicyComponent()).getPolicyComponents().isEmpty());
}

@Test
public void testRecursiveCandidateSearchRequiresIntersectionStepBudget() {
Policy left = buildLateMatchNestedPolicy(LARGE_CANDIDATE_SET, false);
Policy right = buildLateMatchNestedPolicy(LARGE_CANDIDATE_SET, true);

try {
new PolicyIntersector(true).intersect(left, right, true);
fail("Expected RuntimeException due to intersection step budget");
} catch (RuntimeException ex) {
assertTrue(ex.getMessage().contains("intersection steps"));
}
}

private static Policy buildPolicyWithEmptyAlternatives(int alternatives) {
Policy policy = new Policy();
ExactlyOne exactlyOne = new ExactlyOne();
Expand All @@ -91,4 +107,32 @@ private static Policy buildSingleAssertionPolicy(String localName) {

return policy;
}

/**
* Builds one policy alternative containing many same-QName
* policy-containing assertions. The nested policy leaf names are unique;
* reversing order on the second operand forces late unordered matches and
* maximizes recursive candidate search work.
*/
private static Policy buildLateMatchNestedPolicy(int size, boolean reversed) {
Policy policy = new Policy();
ExactlyOne exactlyOne = new ExactlyOne();
All all = new All();

for (int i = 0; i < size; i++) {
int idx = reversed ? size - 1 - i : i;
Policy nested = new Policy();
nested.addPolicyComponent(new PrimitiveAssertion(new QName("urn:test", "leaf" + idx)));

all.addPolicyComponent(new PolicyContainingPrimitiveAssertion(
new QName("urn:test", "n"),
false,
false,
nested));
}

exactlyOne.addPolicyComponent(all);
policy.addPolicyComponent(exactlyOne);
return policy;
}
}
Loading