Skip to content

Commit 620fcbd

Browse files
lukaszlenartclaude
andauthored
WW-5621 Harden XML parsers against Entity Expansion (Billion Laughs) attacks (#1642)
Modern JDKs (7u45+) already protect against this attack with a built-in 64K entity expansion limit. These changes add defense-in-depth hardening and remove unnecessary attack surface. - Remove unused parseStringAsXML feature from StringAdapter to eliminate a theoretical XML Entity Expansion vector - Deprecate setParseStringAsXML() and getParseStringAsXML() for removal - Enable SECURE_PROCESSING feature in DigesterDefinitionsReader - Add unit test verifying JDK's entity expansion limit rejects Billion Laughs payloads - Add research document with vulnerability analysis Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41abbd1 commit 620fcbd

6 files changed

Lines changed: 193 additions & 41 deletions

File tree

core/src/main/java/org/apache/struts2/util/DomHelper.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import org.xml.sax.SAXParseException;
3939
import org.xml.sax.helpers.DefaultHandler;
4040

41+
import javax.xml.XMLConstants;
4142
import javax.xml.parsers.ParserConfigurationException;
4243
import javax.xml.parsers.SAXParser;
4344
import javax.xml.parsers.SAXParserFactory;
@@ -104,6 +105,7 @@ public static Document parse(InputSource inputSource, Map<String, String> dtdMap
104105
try {
105106
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
106107
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
108+
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
107109
} catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) {
108110
throw new StrutsException("Unable to disable resolving external entities!", e);
109111
}

core/src/test/java/org/apache/struts2/util/DomHelperTest.java

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,18 @@
2424
import org.w3c.dom.Element;
2525
import org.w3c.dom.NodeList;
2626
import org.xml.sax.InputSource;
27+
import org.xml.sax.SAXParseException;
2728

2829
import java.io.StringReader;
30+
import java.util.Objects;
2931

3032
/**
3133
* Test cases for {@link DomHelper}.
3234
*/
3335
public class DomHelperTest extends TestCase {
3436

35-
private final String xml = "<!DOCTYPE foo [<!ELEMENT foo (bar)><!ELEMENT bar (#PCDATA)>]>\n<foo>\n<bar/>\n</foo>\n";
36-
3737
public void testParse() {
38+
String xml = "<!DOCTYPE foo [<!ELEMENT foo (bar)><!ELEMENT bar (#PCDATA)>]>\n<foo>\n<bar/>\n</foo>\n";
3839
InputSource in = new InputSource(new StringReader(xml));
3940
in.setSystemId("foo://bar");
4041

@@ -47,6 +48,7 @@ public void testParse() {
4748
}
4849

4950
public void testGetLocationObject() {
51+
String xml = "<!DOCTYPE foo [<!ELEMENT foo (bar)><!ELEMENT bar (#PCDATA)>]>\n<foo>\n<bar/>\n</foo>\n";
5052
InputSource in = new InputSource(new StringReader(xml));
5153
in.setSystemId("foo://bar");
5254

@@ -61,7 +63,7 @@ public void testGetLocationObject() {
6163
}
6264

6365
public void testExternalEntities() {
64-
String dtdFile = getClass().getResource("/author.dtd").getPath();
66+
String dtdFile = Objects.requireNonNull(getClass().getResource("/author.dtd")).getPath();
6567
String xml = "<!DOCTYPE foo [<!ELEMENT foo (bar)><!ELEMENT bar (#PCDATA)><!ENTITY writer SYSTEM \"file://" + dtdFile + "\">]><foo><bar>&writer;</bar></foo>";
6668
InputSource in = new InputSource(new StringReader(xml));
6769
in.setSystemId("foo://bar");
@@ -74,4 +76,34 @@ public void testExternalEntities() {
7476
assertEquals(1, nl.getLength());
7577
assertNull(nl.item(0).getNodeValue());
7678
}
79+
80+
/**
81+
* Tests that the parser is protected against Billion Laughs (XML Entity Expansion) attack.
82+
* The FEATURE_SECURE_PROCESSING flag and the JDK's built-in entity expansion limit (64K
83+
* since JDK 7u45) both cap entity expansion to prevent DoS.
84+
* See: <a href="https://en.wikipedia.org/wiki/Billion_laughs_attack">Billion laughs attack</a>
85+
*/
86+
public void testBillionLaughsProtection() {
87+
String xml = "<?xml version=\"1.0\"?>" +
88+
"<!DOCTYPE root [" +
89+
"<!ENTITY lol0 \"lol\">" +
90+
"<!ENTITY lol1 \"&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;\">" +
91+
"<!ENTITY lol2 \"&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;\">" +
92+
"<!ENTITY lol3 \"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">" +
93+
"<!ENTITY lol4 \"&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;\">" +
94+
"<!ENTITY lol5 \"&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;\">" +
95+
"]>" +
96+
"<root>&lol5;</root>";
97+
98+
InputSource in = new InputSource(new StringReader(xml));
99+
in.setSystemId("test://billion-laughs");
100+
101+
try {
102+
DomHelper.parse(in);
103+
fail("Parser should reject excessive entity expansion");
104+
} catch (Exception e) {
105+
assertNotNull(e.getCause());
106+
assertTrue(e.getCause() instanceof SAXParseException);
107+
}
108+
}
77109
}

plugins/tiles/src/main/java/org/apache/tiles/core/definition/digester/DigesterDefinitionsReader.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.xml.sax.SAXNotSupportedException;
3636
import org.xml.sax.SAXParseException;
3737

38+
import javax.xml.XMLConstants;
3839
import javax.xml.parsers.ParserConfigurationException;
3940
import java.io.IOException;
4041
import java.io.InputStream;
@@ -164,6 +165,8 @@ public DigesterDefinitionsReader() {
164165
// Disable external DTDs as well
165166
digester.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
166167
digester.setXIncludeAware(false);
168+
// Enable secure processing to limit entity expansion (prevents Billion Laughs attack)
169+
digester.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
167170
} catch (ParserConfigurationException | SAXNotRecognizedException | SAXNotSupportedException e) {
168171
throw new StrutsException("Unable to disable external XML entity parsing", e);
169172
}

plugins/tiles/src/test/java/org/apache/tiles/core/definition/digester/TestDigesterDefinitionsReader.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
import org.junit.Before;
2727
import org.junit.Test;
2828

29+
import java.io.ByteArrayInputStream;
2930
import java.io.IOException;
3031
import java.io.InputStream;
3132
import java.net.URL;
33+
import java.nio.charset.StandardCharsets;
3234
import java.util.List;
3335
import java.util.Map;
3436

@@ -268,6 +270,27 @@ public void testReadNoSource() {
268270
assertNull(reader.read(null));
269271
}
270272

273+
/**
274+
* Tests that the Digester parser is protected against Billion Laughs (XML Entity Expansion) attack.
275+
* FEATURE_SECURE_PROCESSING is enabled in DigesterDefinitionsReader to limit entity expansion.
276+
*/
277+
@Test(expected = DefinitionsFactoryException.class)
278+
public void testBillionLaughsProtection() {
279+
String xml = "<?xml version=\"1.0\"?>" +
280+
"<!DOCTYPE root [" +
281+
"<!ENTITY lol0 \"lol\">" +
282+
"<!ENTITY lol1 \"&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;&lol0;\">" +
283+
"<!ENTITY lol2 \"&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;&lol1;\">" +
284+
"<!ENTITY lol3 \"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">" +
285+
"<!ENTITY lol4 \"&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;\">" +
286+
"<!ENTITY lol5 \"&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;&lol4;\">" +
287+
"]>" +
288+
"<root>&lol5;</root>";
289+
290+
InputStream source = new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8));
291+
reader.read(source);
292+
}
293+
271294
/**
272295
* Tests {@link DigesterDefinitionsReader#addDefinition(Definition)}.
273296
*/

plugins/xslt/src/main/java/org/apache/struts2/result/xslt/StringAdapter.java

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,10 @@
1818
*/
1919
package org.apache.struts2.result.xslt;
2020

21-
import org.apache.struts2.util.DomHelper;
2221
import org.apache.logging.log4j.LogManager;
2322
import org.apache.logging.log4j.Logger;
2423
import org.w3c.dom.Node;
25-
import org.xml.sax.InputSource;
2624

27-
import java.io.StringReader;
2825
import java.util.ArrayList;
2926
import java.util.List;
3027

@@ -39,17 +36,13 @@
3936
*
4037
* <p>
4138
* Subclasses may override the getStringValue() method in order to use StringAdapter
42-
* as a simplified custom XML adapter for Java types. A subclass can enable XML
43-
* parsing of the value string via the setParseStringAsXML() method and then
44-
* override getStringValue() to return a String containing the custom formatted XML.
39+
* as a simplified custom XML adapter for Java types.
4540
* </p>
4641
*/
4742
public class StringAdapter extends AbstractAdapterElement {
4843

4944
private static final Logger LOG = LogManager.getLogger(StringAdapter.class);
5045

51-
boolean parseStringAsXML;
52-
5346
public StringAdapter() {
5447
}
5548

@@ -58,16 +51,11 @@ public StringAdapter(AdapterFactory adapterFactory, AdapterNode parent, String p
5851
}
5952

6053
/**
61-
* <p>
6254
* Get the object to be adapted as a String value.
63-
* </p>
6455
*
6556
* <p>
6657
* This method can be overridden by subclasses that wish to use StringAdapter
67-
* as a simplified customizable XML adapter for Java types. A subclass can
68-
* enable parsing of the value string as containing XML text via the
69-
* setParseStringAsXML() method and then override getStringValue() to return a
70-
* String containing the custom formatted XML.
58+
* as a simplified customizable XML adapter for Java types.
7159
* </p>
7260
*
7361
* @return the string value
@@ -78,44 +66,32 @@ protected String getStringValue() {
7866

7967
@Override
8068
protected List<Node> buildChildAdapters() {
81-
Node node;
82-
if (getParseStringAsXML()) {
83-
LOG.debug("parsing string as xml: {}", getStringValue());
84-
// Parse the String to a DOM, then proxy that as our child
85-
node = DomHelper.parse(new InputSource(new StringReader(getStringValue())));
86-
node = getAdapterFactory().proxyNode(this, node);
87-
} else {
88-
LOG.debug("using string as is: {}", getStringValue());
89-
// Create a Text node as our child
90-
node = new SimpleTextNode(getAdapterFactory(), this, "text", getStringValue());
91-
}
69+
LOG.debug("using string as is: {}", getStringValue());
70+
Node node = new SimpleTextNode(getAdapterFactory(), this, "text", getStringValue());
9271

9372
List<Node> children = new ArrayList<>();
9473
children.add(node);
9574
return children;
9675
}
9776

9877
/**
99-
* @return is this StringAdapter to interpret its string values as containing
100-
* XML Text?
101-
*
102-
* @see #setParseStringAsXML(boolean)
78+
* @return always returns false
79+
* @deprecated This feature has been removed for security reasons (potential XML Entity Expansion attacks).
80+
* This method now always returns false and will be removed in a future version.
10381
*/
82+
@Deprecated(forRemoval = true, since = "7.2.0")
10483
public boolean getParseStringAsXML() {
105-
return parseStringAsXML;
84+
return false;
10685
}
10786

10887
/**
109-
* When set to true the StringAdapter will interpret its String value
110-
* as containing XML text and parse it to a DOM Element. The new DOM
111-
* Element will be a child of this String element. (i.e. wrapped in an
112-
* element of the property name specified for this StringAdapter).
113-
*
114-
* @param parseStringAsXML when set to true the StringAdapter will interpret its String value as containing XML text
115-
* @see #getParseStringAsXML()
88+
* @param parseStringAsXML ignored
89+
* @deprecated This feature has been removed for security reasons (potential XML Entity Expansion attacks).
90+
* This method is now a no-op and will be removed in a future version.
11691
*/
92+
@Deprecated(forRemoval = true, since = "7.2.0")
11793
public void setParseStringAsXML(boolean parseStringAsXML) {
118-
this.parseStringAsXML = parseStringAsXML;
94+
// no-op - feature removed for security reasons
11995
}
12096

12197
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
date: 2026-01-13T00:00:00Z
3+
topic: "XML Entity Expansion (Billion Laughs) Hardening Analysis"
4+
tags: [research, security, xxe, billion-laughs, DomHelper, xml-parsing, hardening]
5+
jira: WW-5621
6+
status: complete
7+
severity: Low (hardening only - not an exploitable vulnerability)
8+
---
9+
10+
# Research: XML Entity Expansion (Billion Laughs) Hardening Analysis
11+
12+
**Date**: 2026-01-13
13+
**Jira**: [WW-5621](https://issues.apache.org/jira/browse/WW-5621)
14+
15+
## Research Question
16+
Assess the scope of a reported Billion Laughs DoS concern in Apache Struts XML parsers and determine appropriate hardening measures.
17+
18+
## Summary
19+
20+
**NOT A VULNERABILITY**: While the SAX parser in `DomHelper.java` does not explicitly block DOCTYPE declarations with internal entity expansion, modern JDKs (7u45+) enforce a built-in 64,000 entity expansion limit that already prevents Billion Laughs attacks. Additionally, none of the `DomHelper.parse()` call sites accept user-supplied input — all XML sources come from the classpath.
21+
22+
This analysis led to **defense-in-depth hardening** and removal of an unnecessary feature that could theoretically become an attack surface if misused by custom application code.
23+
24+
## Why This Is Not a Vulnerability
25+
26+
1. **JDK protection is already in place**: Since JDK 7u45, the XML parser enforces a hard limit of 64,000 entity expansions. A Billion Laughs payload is rejected with a `SAXParseException` before causing meaningful resource consumption. This is verified by a unit test.
27+
28+
2. **No user-controlled input reaches the parsers**: All callers of `DomHelper.parse()` load XML exclusively from the classpath via `ClassLoader.getResources()`:
29+
- `XmlConfigurationProvider` — parses `struts.xml` and `<include>` files
30+
- `DefaultValidatorFileParser` — parses `*-validation.xml` and `*-validators.xml`
31+
32+
3. **The StringAdapter path was disabled by default**: `parseStringAsXML` defaulted to `false`, no subclasses of `StringAdapter` exist in the codebase, and enabling it required custom Java code across three layers (custom adapter, custom result, struts.xml registration).
33+
34+
## Detailed Findings
35+
36+
### XML Parser Configuration in DomHelper
37+
38+
**File:** `core/src/main/java/org/apache/struts2/util/DomHelper.java`
39+
40+
**Current protection:**
41+
```java
42+
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
43+
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
44+
```
45+
46+
External entities are properly blocked. Internal entity expansion (used by Billion Laughs) is handled by the JDK's built-in limit.
47+
48+
### DomHelper.parse() Call Sites
49+
50+
| Location | What is parsed | User input? |
51+
|----------|---------------|-------------|
52+
| `XmlConfigurationProvider.java:173` | struts.xml + includes from classpath | No |
53+
| `DefaultValidatorFileParser.java:94` | Action validation files from classpath | No |
54+
| `DefaultValidatorFileParser.java:131` | Validator definitions from classpath | No |
55+
56+
All sources are classpath resources. An attacker would need write access to the application's classpath (WEB-INF/classes or deployed JARs) to inject a malicious XML file — at which point they already have full control of the application.
57+
58+
### StringAdapter.parseStringAsXML (removed)
59+
60+
**File:** `plugins/xslt/src/main/java/org/apache/struts2/result/xslt/StringAdapter.java`
61+
62+
This feature allowed a `StringAdapter` subclass to parse its string value as XML via `DomHelper.parse()`. While disabled by default, it represented unnecessary attack surface:
63+
- No subclasses of `StringAdapter` exist anywhere in the codebase
64+
- Enabling it required custom Java code, not just configuration
65+
- The XSLT plugin itself is niche
66+
67+
**Decision**: Remove the feature entirely and deprecate the API methods for future removal.
68+
69+
### Tiles Plugin - DigesterDefinitionsReader
70+
71+
**File:** `plugins/tiles/src/main/java/org/apache/tiles/core/definition/digester/DigesterDefinitionsReader.java`
72+
73+
Already had good protection (external entities and DTD loading disabled). Added `FEATURE_SECURE_PROCESSING` as defense-in-depth. Only parses internal Tiles definition files from the classpath.
74+
75+
### XSLTResult TransformerFactory
76+
77+
**File:** `plugins/xslt/src/main/java/org/apache/struts2/result/xslt/XSLTResult.java`
78+
79+
Already properly secured:
80+
```java
81+
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
82+
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
83+
```
84+
85+
No changes needed.
86+
87+
## Hardening Measures Applied
88+
89+
1. **Removed `parseStringAsXML` from StringAdapter** — eliminates a theoretical attack surface that could be misused by custom application code
90+
2. **Deprecated `getParseStringAsXML()` and `setParseStringAsXML()`** — marked for removal in a future version
91+
3. **Enabled `FEATURE_SECURE_PROCESSING` in DigesterDefinitionsReader** — defense-in-depth
92+
4. **Added unit test** — verifies the JDK's 64K entity expansion limit rejects Billion Laughs payloads, serving as a regression guard
93+
94+
## Entity Expansion Impact (for reference)
95+
96+
Without the JDK limit, a Billion Laughs payload would cause:
97+
98+
| Level | Payload | Memory | Time |
99+
|-------|---------|--------|------|
100+
| 3 | ~500 bytes | 3 KB | 35 ms |
101+
| 5 | ~500 bytes | 300 KB | 91 ms |
102+
| 7 | ~500 bytes | 30 MB | 3408 ms, 1837 MB memory |
103+
104+
The JDK limit stops expansion at 64,000 entities, well before these levels become dangerous.
105+
106+
## Code References
107+
108+
- `core/src/main/java/org/apache/struts2/util/DomHelper.java` — XML parser with external entity protection
109+
- `plugins/xslt/src/main/java/org/apache/struts2/result/xslt/StringAdapter.java` — removed parseStringAsXML feature
110+
- `plugins/tiles/src/main/java/org/apache/tiles/core/definition/digester/DigesterDefinitionsReader.java` — added SECURE_PROCESSING
111+
- `core/src/test/java/org/apache/struts2/util/DomHelperTest.java` — Billion Laughs regression test
112+
113+
## References
114+
115+
- OWASP XXE Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
116+
- Billion Laughs Attack: https://en.wikipedia.org/wiki/Billion_laughs_attack

0 commit comments

Comments
 (0)