This is an automated email from the ASF dual-hosted git repository.
coheigea pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ws-xmlschema.git
The following commit(s) were added to refs/heads/master by this push:
new be55147f Improve cycle detection in the schema walker (#127)
be55147f is described below
commit be55147fac5cae647c7894f913edbbc83c36e4c8
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Mon Aug 24 14:32:53 2026 +0100
Improve cycle detection in the schema walker (#127)
---
THREAT-MODEL.md | 40 +++++-
.../ws/commons/schema/walker/XmlSchemaScope.java | 38 ++++-
.../ws/commons/schema/walker/XmlSchemaWalker.java | 58 ++++++--
.../schema/walker/CyclicSchemaWalkerTest.java | 160 +++++++++++++++++++++
4 files changed, 278 insertions(+), 18 deletions(-)
diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md
index 12664adf..4dc3afaa 100644
--- a/THREAT-MODEL.md
+++ b/THREAT-MODEL.md
@@ -179,7 +179,12 @@ A finding is in-model only if it reaches a row marked
**yes**.
`DocumentBuilderFactory` decided that. In-model for whatever the
schema-model semantics imply about the DOM contents.
- **`xmlschema-walker`**: in-model when the visitor walks an
- attacker-controlled schema. The walker is purely in-memory.
+ attacker-controlled schema. The walker is purely in-memory. Before cycle
+ detection, malformed but parseable schemas could create cycles in type
+ derivation, substitution groups, model groups, or attribute groups and
+ recurse until `StackOverflowError`. The walker now tracks these expansion
+ paths and rejects cyclic re-entry with `XmlSchemaException`; recursion that
+ crosses an element declaration remains supported.
- **`XmlSchemaPathFinder`**: in-model when caller-supplied SAX events
are matched against an attacker-controlled schema. Its backtracking
work is bounded per document by configurable decision-point and
@@ -209,7 +214,9 @@ A finding is in-model only if it reaches a row marked
**yes**.
*(inferred — §14 Q7)*.
- **Memory**: schemas are held in memory; XMLSchema has no built-in
ceiling on schema-document size, number of imports, or import-graph
- depth *(inferred — §14 Q8)*.
+ depth *(inferred — §14 Q8)*. The walker has active-path cycle detection
+ for the schema expansion graphs it traverses, but large acyclic schemas
+ may still consume substantial memory and CPU.
- **System properties**: `org.apache.ws.commons.schema.extension_registry`
is consulted at `XmlSchemaCollection` construction time, and the
named class is loaded via `Class.forName()` *(documented:
@@ -299,6 +306,10 @@ feature is the intended defense and is sufficient) or
`MODEL-GAP`
- No documented limit on schema-document size *(inferred — §14 Q8)*.
- No documented limit on the import-graph depth or breadth
*(inferred — §14 Q8)*.
+- Walker expansion cycles are rejected for type derivation, substitution
+ groups, model groups, and attribute groups. This prevents recursive
+ stack exhaustion for malformed but parseable schemas; it is not a general
+ limit on the size or cost of an acyclic schema.
- No rate limit on URL fetches when following `xs:import`
*(inferred — §14 Q12)*.
- `XmlSchemaPathFinder` bounds decision points and replayed events per
@@ -375,6 +386,19 @@ feature is the intended defense and is sufficient) or
`MODEL-GAP`
- **Severity**: **correctness-only**.
- *(inferred — §14 Q17)*
+### P5 — Bounded recursive schema walking
+
+- **Condition**: an attacker-controlled schema is passed to
+ `XmlSchemaWalker.walk(XmlSchemaElement)`.
+- **Property**: cyclic type derivation, substitution-group, model-group,
+ and attribute-group expansions terminate with `XmlSchemaException` rather
+ than recursing indefinitely. Legal recursive content that passes through
+ an element declaration remains walkable.
+- **Violation symptom**: a parseable schema causes the walker to recurse
+ until `StackOverflowError` or another resource-exhaustion failure.
+- **Severity**: **medium** availability impact when the embedding
+ application accepts untrusted schemas *(inferred — §14 Q23)*.
+
## §9 Security properties the project does *not* provide
State each plainly so a triager can route an inbound report to the
@@ -452,7 +476,8 @@ matching disclaimer.
- **Billion-laughs / quadratic blowup** — partially mitigated by
`FEATURE_SECURE_PROCESSING=true`, but not universally.
- **Schema-amplification DoS** — a deeply nested or heavily-recursive
- schema can exhaust memory.
+ acyclic schema can exhaust memory or CPU; cyclic walker expansion is
+ rejected as described in §8 P5.
- **Confused-deputy fetch via untrusted `baseUri` + relative
`schemaLocation`** — the operator-supplied base URI is trusted.
@@ -760,6 +785,14 @@ sees recur in inbound reports. *(meta — §11a)*
**Q22.** What kind of change to XMLSchema should trigger a revision
(proposed list in §12 — confirm or correct)? *(meta — §12)*
+**Q23.** `xmlschema-walker` cycle handling: should rejection of cyclic
+type derivation, substitution-group, model-group, and attribute-group
+expansions be treated as a claimed medium-severity availability property
+for attacker-controlled schemas? Proposed: **yes**; malformed but
+parseable cycles must terminate with `XmlSchemaException`, while legal
+recursive content through element declarations remains supported. *(maps
+to §4, §6, §8 P5)*
+
---
## Appendix: SECURITY.md / website → §x back-map
@@ -781,3 +814,4 @@ source comments. The project website is
| `xmlschema-core/src/main/java/.../resolver/URIResolver.java` | Resolver
interface — caller-pluggable | §2 caller-roles, §10 item 1 |
| `xmlschema-walker/src/main/java/.../docpath/DomBuilderFromSax.java` line 81
| `factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, TRUE)` | §5a, §8
P2 |
| `xmlschema-walker/src/main/java/.../docpath/XmlSchemaPathFinder.java` |
Configurable per-document limits on decision points and replayed events | §4,
§5a, §6, §12 |
+| `xmlschema-walker/src/main/java/.../walker/XmlSchemaWalker.java` and
`XmlSchemaScope.java` | Active-path cycle detection for schema expansion | §4,
§6, §8 P5 |
diff --git
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaScope.java
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaScope.java
index 475f5aab..fcd7b674 100644
---
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaScope.java
+++
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaScope.java
@@ -21,8 +21,10 @@ package org.apache.ws.commons.schema.walker;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -42,6 +44,7 @@ import
org.apache.ws.commons.schema.XmlSchemaComplexContentRestriction;
import org.apache.ws.commons.schema.XmlSchemaComplexType;
import org.apache.ws.commons.schema.XmlSchemaContent;
import org.apache.ws.commons.schema.XmlSchemaElement;
+import org.apache.ws.commons.schema.XmlSchemaException;
import org.apache.ws.commons.schema.XmlSchemaFacet;
import org.apache.ws.commons.schema.XmlSchemaParticle;
import org.apache.ws.commons.schema.XmlSchemaSequence;
@@ -71,6 +74,8 @@ final class XmlSchemaScope {
private XmlSchemaParticle child;
private XmlSchemaAnyAttribute anyAttr;
private Set<QName> userRecognizedTypes;
+ private Set<XmlSchemaType> typesInProgress;
+ private final Set<QName> attributeGroupsInProgress = new HashSet<QName>();
/**
* Initialization of members to be filled in during the walk.
@@ -87,8 +92,9 @@ final class XmlSchemaScope {
this.schemasByNamespace = child.schemasByNamespace;
this.scopeCache = child.scopeCache;
this.userRecognizedTypes = child.userRecognizedTypes;
+ this.typesInProgress = child.typesInProgress;
- walk(type);
+ walkWithCycleCheck(type);
}
/**
@@ -104,8 +110,22 @@ final class XmlSchemaScope {
schemasByNamespace = xmlSchemasByNamespace;
this.scopeCache = scopeCache;
this.userRecognizedTypes = userRecognizedTypes;
+ this.typesInProgress =
+ Collections.newSetFromMap(new IdentityHashMap<XmlSchemaType,
Boolean>());
- walk(type);
+ walkWithCycleCheck(type);
+ }
+
+ private void walkWithCycleCheck(XmlSchemaType type) {
+ if (!typesInProgress.add(type)) {
+ throw new XmlSchemaException("Cyclic type derivation detected
involving type "
+ + getName(type, "{Anonymous}") + '.');
+ }
+ try {
+ walk(type);
+ } finally {
+ typesInProgress.remove(type);
+ }
}
/**
@@ -494,7 +514,19 @@ final class XmlSchemaScope {
if (attrGroup == null) {
attrGroup =
schemasByNamespace.getAttributeGroupByName(groupRef.getTargetQName());
}
- return getAttributesOf(attrGroup);
+
+ final QName groupName = groupRef.getTargetQName();
+ if ((groupName != null) && !attributeGroupsInProgress.add(groupName)) {
+ throw new XmlSchemaException("Cyclic attribute group reference
detected involving "
+ + groupName + '.');
+ }
+ try {
+ return getAttributesOf(attrGroup);
+ } finally {
+ if (groupName != null) {
+ attributeGroupsInProgress.remove(groupName);
+ }
+ }
}
private ArrayList<XmlSchemaAttrInfo>
getAttributesOf(XmlSchemaAttributeGroup attrGroup) {
diff --git
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaWalker.java
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaWalker.java
index 4c978502..4a906677 100644
---
a/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaWalker.java
+++
b/xmlschema-walker/src/main/java/org/apache/ws/commons/schema/walker/XmlSchemaWalker.java
@@ -22,6 +22,7 @@ package org.apache.ws.commons.schema.walker;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
@@ -38,6 +39,7 @@ import org.apache.ws.commons.schema.XmlSchemaChoiceMember;
import org.apache.ws.commons.schema.XmlSchemaCollection;
import org.apache.ws.commons.schema.XmlSchemaComplexType;
import org.apache.ws.commons.schema.XmlSchemaElement;
+import org.apache.ws.commons.schema.XmlSchemaException;
import org.apache.ws.commons.schema.XmlSchemaGroup;
import org.apache.ws.commons.schema.XmlSchemaGroupParticle;
import org.apache.ws.commons.schema.XmlSchemaGroupRef;
@@ -45,7 +47,6 @@ import org.apache.ws.commons.schema.XmlSchemaParticle;
import org.apache.ws.commons.schema.XmlSchemaSequence;
import org.apache.ws.commons.schema.XmlSchemaSequenceMember;
import org.apache.ws.commons.schema.XmlSchemaType;
-import org.apache.ws.commons.schema.utils.XmlSchemaNamed;
/**
* Walks an {@link XmlSchema} from a starting {@link XmlSchemaElement},
@@ -61,6 +62,8 @@ public final class XmlSchemaWalker {
private final SchemasByNamespace schemasByNamespace;
private final Map<QName, XmlSchemaScope> scopeCache;
private final IdentityHashMap<XmlSchemaType, XmlSchemaType> visitedTypes;
+ private Set<QName> substGroupsInProgress = new HashSet<QName>();
+ private Set<QName> groupsInProgress = new HashSet<QName>();
/**
* Initializes the {@link XmlSchemaWalker} with the
@@ -147,6 +150,8 @@ public final class XmlSchemaWalker {
public void clear() {
scopeCache.clear();
visitedTypes.clear();
+ substGroupsInProgress.clear();
+ groupsInProgress.clear();
}
/**
@@ -301,7 +306,16 @@ public final class XmlSchemaWalker {
// 6. Walk the child groups and elements (if any), depth-first.
final XmlSchemaParticle child = scope.getParticle();
if (child != null) {
- walk(child);
+ final Set<QName> outerSubstGroupsInProgress =
substGroupsInProgress;
+ final Set<QName> outerGroupsInProgress = groupsInProgress;
+ substGroupsInProgress = new HashSet<QName>();
+ groupsInProgress = new HashSet<QName>();
+ try {
+ walk(child);
+ } finally {
+ substGroupsInProgress = outerSubstGroupsInProgress;
+ groupsInProgress = outerGroupsInProgress;
+ }
}
}
@@ -320,12 +334,21 @@ public final class XmlSchemaWalker {
// 8. Now handle substitute elements, if any.
if (substitutes != null) {
- for (XmlSchemaElement substitute : substitutes) {
- walk(substitute);
+ final QName substGroupName = getElementQName(substGroupElem);
+ if (!substGroupsInProgress.add(substGroupName)) {
+ throw new XmlSchemaException("Cyclic substitution group
detected involving "
+ + substGroupName + '.');
}
+ try {
+ for (XmlSchemaElement substitute : substitutes) {
+ walk(substitute);
+ }
- for (XmlSchemaVisitor visitor : visitors) {
- visitor.onExitSubstitutionGroup(substGroupElem);
+ for (XmlSchemaVisitor visitor : visitors) {
+ visitor.onExitSubstitutionGroup(substGroupElem);
+ }
+ } finally {
+ substGroupsInProgress.remove(substGroupName);
}
}
}
@@ -333,14 +356,25 @@ public final class XmlSchemaWalker {
private void walk(XmlSchemaParticle particle) {
if (particle instanceof XmlSchemaGroupRef) {
XmlSchemaGroupRef groupRef = (XmlSchemaGroupRef)particle;
- XmlSchemaGroupParticle group = groupRef.getParticle();
- if (group == null) {
- XmlSchemaGroup g =
schemasByNamespace.getGroupByName(groupRef.getRefName());
- if (g != null) {
- group = g.getParticle();
+ final QName groupName = groupRef.getRefName();
+ if ((groupName != null) && !groupsInProgress.add(groupName)) {
+ throw new XmlSchemaException("Cyclic group reference detected
involving group "
+ + groupName + '.');
+ }
+ try {
+ XmlSchemaGroupParticle group = groupRef.getParticle();
+ if (group == null) {
+ XmlSchemaGroup g =
schemasByNamespace.getGroupByName(groupName);
+ if (g != null) {
+ group = g.getParticle();
+ }
+ }
+ walk(group, groupRef.getMinOccurs(), groupRef.getMaxOccurs());
+ } finally {
+ if (groupName != null) {
+ groupsInProgress.remove(groupName);
}
}
- walk(group, groupRef.getMinOccurs(), groupRef.getMaxOccurs());
} else if (particle instanceof XmlSchemaGroupParticle) {
walk((XmlSchemaGroupParticle)particle, particle.getMinOccurs(),
particle.getMaxOccurs());
diff --git
a/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/walker/CyclicSchemaWalkerTest.java
b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/walker/CyclicSchemaWalkerTest.java
new file mode 100644
index 00000000..c29e0f90
--- /dev/null
+++
b/xmlschema-walker/src/test/java/org/apache/ws/commons/schema/walker/CyclicSchemaWalkerTest.java
@@ -0,0 +1,160 @@
+/**
+ * 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.ws.commons.schema.walker;
+
+import java.io.StringReader;
+
+import javax.xml.namespace.QName;
+
+import org.apache.ws.commons.schema.XmlSchemaCollection;
+import org.apache.ws.commons.schema.XmlSchemaElement;
+import org.apache.ws.commons.schema.XmlSchemaException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class CyclicSchemaWalkerTest extends Assert {
+
+ private static final String XSD_HEADER =
+ "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\""
+ + " xmlns:tns=\"urn:cyclic\" targetNamespace=\"urn:cyclic\">";
+
+ private XmlSchemaElement load(String schemaBody, String elementName,
+ XmlSchemaCollection collection) {
+ collection.read(new StringReader(XSD_HEADER + schemaBody +
"</xs:schema>"));
+ XmlSchemaElement element =
+ collection.getElementByQName(new QName("urn:cyclic", elementName));
+ assertNotNull(element);
+ return element;
+ }
+
+ @Test
+ public void testRecursiveContentModelThroughElementWalks() {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ XmlSchemaElement element =
+ load("<xs:group name=\"content\"><xs:sequence>"
+ + "<xs:element ref=\"tns:section\"/>"
+ + "</xs:sequence></xs:group>"
+ + "<xs:element name=\"section\"><xs:complexType><xs:sequence>"
+ + "<xs:group ref=\"tns:content\" minOccurs=\"0\"/>"
+ + "</xs:sequence></xs:complexType></xs:element>"
+ + "<xs:element name=\"root\"><xs:complexType><xs:sequence>"
+ + "<xs:group ref=\"tns:content\"/>"
+ + "</xs:sequence></xs:complexType></xs:element>",
+ "root", collection);
+
+ new XmlSchemaWalker(collection).walk(element);
+ }
+
+ @Test
+ public void testNestedValidSubstitutionGroupReferenceWalks() {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ XmlSchemaElement element =
+ load("<xs:complexType name=\"HeadType\"><xs:sequence>"
+ + "<xs:element name=\"leaf\" type=\"xs:string\"
minOccurs=\"0\"/>"
+ + "</xs:sequence></xs:complexType>"
+ + "<xs:complexType name=\"BranchType\"><xs:complexContent>"
+ + "<xs:extension base=\"tns:HeadType\"><xs:sequence>"
+ + "<xs:element ref=\"tns:node\" minOccurs=\"0\"/>"
+ + "</xs:sequence></xs:extension>"
+ + "</xs:complexContent></xs:complexType>"
+ + "<xs:element name=\"node\" type=\"tns:HeadType\"/>"
+ + "<xs:element name=\"branch\" type=\"tns:BranchType\""
+ + " substitutionGroup=\"tns:node\"/>",
+ "node", collection);
+
+ new XmlSchemaWalker(collection).walk(element);
+ }
+
+ @Test
+ public void testSelfSubstitutionGroupIsRejected() {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ XmlSchemaElement element =
+ load("<xs:element name=\"a\" type=\"xs:string\""
+ + " substitutionGroup=\"tns:a\"/>", "a", collection);
+
+ assertThrowsXmlSchemaException(element, collection);
+ }
+
+ @Test
+ public void testMutualSubstitutionGroupCycleIsRejected() {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ XmlSchemaElement element =
+ load("<xs:element name=\"a\" type=\"xs:string\""
+ + " substitutionGroup=\"tns:b\"/>"
+ + "<xs:element name=\"b\" type=\"xs:string\""
+ + " substitutionGroup=\"tns:a\"/>", "a", collection);
+
+ assertThrowsXmlSchemaException(element, collection);
+ }
+
+ @Test
+ public void testCyclicGroupReferencesAreRejected() {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ XmlSchemaElement element =
+ load("<xs:group name=\"g1\"><xs:sequence><xs:group
ref=\"tns:g2\"/>"
+ + "</xs:sequence></xs:group>"
+ + "<xs:group name=\"g2\"><xs:sequence><xs:group
ref=\"tns:g1\"/>"
+ + "</xs:sequence></xs:group>"
+ + "<xs:element name=\"root\"><xs:complexType><xs:sequence>"
+ + "<xs:group ref=\"tns:g1\"/>"
+ + "</xs:sequence></xs:complexType></xs:element>",
+ "root", collection);
+
+ assertThrowsXmlSchemaException(element, collection);
+ }
+
+ @Test
+ public void testCyclicTypeDerivationIsRejected() {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ XmlSchemaElement element =
+ load("<xs:complexType name=\"A\"><xs:complexContent>"
+ + "<xs:extension base=\"tns:A\"/>"
+ + "</xs:complexContent></xs:complexType>"
+ + "<xs:element name=\"e\" type=\"tns:A\"/>", "e", collection);
+
+ assertThrowsXmlSchemaException(element, collection);
+ }
+
+ @Test
+ public void testCyclicAttributeGroupReferencesAreRejected() {
+ XmlSchemaCollection collection = new XmlSchemaCollection();
+ XmlSchemaElement element =
+ load("<xs:attributeGroup name=\"ag1\"><xs:attributeGroup
ref=\"tns:ag2\"/>"
+ + "</xs:attributeGroup>"
+ + "<xs:attributeGroup name=\"ag2\"><xs:attributeGroup
ref=\"tns:ag1\"/>"
+ + "</xs:attributeGroup>"
+ + "<xs:complexType name=\"C\"><xs:attributeGroup
ref=\"tns:ag1\"/>"
+ + "</xs:complexType>"
+ + "<xs:element name=\"e\" type=\"tns:C\"/>", "e", collection);
+
+ assertThrowsXmlSchemaException(element, collection);
+ }
+
+ private void assertThrowsXmlSchemaException(XmlSchemaElement element,
+ XmlSchemaCollection
collection) {
+ try {
+ new XmlSchemaWalker(collection).walk(element);
+ fail("Expected cyclic schema expansion to be rejected.");
+ } catch (XmlSchemaException expected) {
+ // Expected: malformed schema expansion is bounded by the walker.
+ }
+ }
+}