Configure the XML parser to refuse the DTD: in Java set the Apache feature disallow-doctype-decl to true on the DocumentBuilderFactory, so any DOCTYPE is a fatal error and no external entity (CWE-611) or entity expansion (CWE-776) runs. Add the external-entity and secure-processing features as defence in depth. Do not blocklist '<!DOCTYPE' in the bytes; a UTF-16 copy evades it.
- Find every DocumentBuilderFactory, SAXParserFactory, XMLInputFactory, TransformerFactory, SchemaFactory and Validator that parses request data, plus libraries that wrap them.
- On a DocumentBuilderFactory set setFeature("http://apache.org/xml/features/disallow-doctype-decl", true). This alone stops XXE and entity expansion by making any DOCTYPE a fatal error.
- Add the cheat-sheet features as defence in depth: external-general-entities false, external-parameter-entities false, nonvalidating/load-external-dtd false, FEATURE_SECURE_PROCESSING true, setXIncludeAware(false), setExpandEntityReferences(false).
- Where a DTD is genuinely required, keep the external-entity features false and install an EntityResolver that throws, rather than allowing the DOCTYPE; never rely on the parser's defaults.
- Catch the SAXParseException ("DOCTYPE is disallowed") and return a short 400; do not echo the parser message, and log the detail server-side.
- Add regression tests with this card's payloads (an external file entity and a small bounded expansion, each as UTF-8 and UTF-16) and cap the request body size.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
// No hardening at all: every feature is left at its unsafe default.
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();With IMPL=vulnerable, 4 of 9 tests fail (4 exploit tests).
The fix: refuse the DTD
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// The single most effective control: refuse the DOCTYPE outright.
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
// Belt and braces for parsers that still allow a DTD:
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);With IMPL=fixed, 9 of 9 tests pass.
The fix: parse untrusted bytes
DocumentBuilder builder = factory.newDocumentBuilder();
builder.setErrorHandler(ContactParser.quietHandler());
Document doc = builder.parse(new ByteArrayInputStream(xml));
return ContactParser.extract(doc);With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
private static boolean looksLikeDtd(byte[] xml) {
// ISO-8859-1 keeps every byte as a character, so this is a byte-level
// search; lower-casing only makes it feel thorough. UTF-16 bytes decode
// to interleaved NULs here, so the ASCII markers below never match.
String text = new String(xml, StandardCharsets.ISO_8859_1).toLowerCase(Locale.ROOT);
return text.contains("<!doctype") || text.contains("<!entity");
}With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Each exploit writes a marker file in the test's own temp directory and
// points an external entity at it; the attack must leave no trace in the
// response and be refused. The marker is deleted afterwards.
private void assertRefused(byte[] payload, String needle) throws Exception {
MockHttpServletResponse res = mvc.perform(
post("/import").content(payload).contentType(MediaType.APPLICATION_XML))
.andReturn().getResponse();
String body = res.getContentAsString(StandardCharsets.UTF_8);
assertThat(body).as("XXE leak: the attack output appeared in the response")
.doesNotContain(needle);
assertThat(res.getStatus()).as("the DTD should be refused with 400").isEqualTo(400);
}
private void withMarkerFile(ThrowingConsumer body) throws Exception {
Path marker = Files.createTempFile("xxe-marker-", ".txt");
String text = "XXE-FILEMARKER-" + UUID.randomUUID().toString().replace("-", "");
Files.writeString(marker, text, StandardCharsets.UTF_8);
try {
body.accept(marker.toUri().toString(), text);
} finally {
Files.deleteIfExists(marker);
}
}
@FunctionalInterface
private interface ThrowingConsumer {
void accept(String markerUri, String markerText) throws Exception;
}
@Test
void exploitExternalEntityFileUtf8() throws Exception {
withMarkerFile((uri, text) -> assertRefused(utf8(extFile(uri)), text));
}
@Test
void exploitExternalEntityFileUtf16() throws Exception {
withMarkerFile((uri, text) -> assertRefused(utf16(extFile(uri)), text));
}
@Test
void exploitEntityExpansionUtf8() throws Exception {
assertRefused(utf8(EXPANSION), EXPANSION_MARKER);
}
@Test
void exploitEntityExpansionUtf16() throws Exception {
assertRefused(utf16(EXPANSION), EXPANSION_MARKER);
}- Java's XML parsers are unsafe by default: a stock DocumentBuilderFactory resolves external general entities and expands the DTD, so omitting the hardening is the whole bug.
- disallow-doctype-decl is the one feature that stops both CWE-611 and CWE-776; the external-entity features alone still allow a billion-laughs expansion because the DTD is still processed.
- setExpandEntityReferences(false) is not a security control on its own (the cheat sheet says so); it changes how entity references appear in the tree, not whether external resources are fetched. Disable the DOCTYPE.
- A byte or string blocklist for '<!DOCTYPE'/'<!ENTITY' is bypassed by sending the same document as UTF-16 (or UTF-7); the ASCII bytes are absent but the parser honours the encoding declaration. Reject at the parser.
- FEATURE_SECURE_PROCESSING caps entity expansion but does not disable external entities, and its limits are generous; a small bounded expansion still runs under it. Refuse the DOCTYPE to be certain.
- Semgrep's rule only fires when newDocumentBuilder() sits in the same method as the factory; hiding the builder behind a helper makes the hole invisible to it, so a clean scan is not proof.
- The same traps apply to SAXParserFactory, XMLInputFactory (StAX), TransformerFactory and unmarshalling (JAXB, XMLDecoder); fix every XML entry point, not just DOM.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs java-spring does this for you).
- Each exploit posts a DOCTYPE document (as UTF-8 and UTF-16) whose external entity reads a marker file the test wrote in its own temp dir, or expands a small bounded entity, and asserts the marker/expansion never appears and the request is refused with 400.
- Run semgrep --config r/java.lang.security.audit.xxe.documentbuilderfactory-disallow-doctype-decl-missing.documentbuilderfactory-disallow-doctype-decl-missing src/main/java; expect findings on vulnerable and wrong only, then confirm the fixed configuration by hand.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | java.lang.security.audit.xxe.documentbuilderfactory-disallow-doctype-decl-missing.documentbuilderfactory-disallow-doctype-decl-missing | Flagged VulnerableContactParser.java:26 and WrongContactParser.java:41 (newDocumentBuilder() on a factory that never set disallow-doctype-decl) and nothing on FixedContactParser.java, which sets it true. Caveat confirmed on a probe: the pattern needs the factory variable and newDocumentBuilder() in the same method; a builder created in a helper evades it, so the card inlines the call.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/java.lang.security.audit.xxe.documentbuilderfactory-disallow-doctype-decl-missing.documentbuilderfactory-disallow-doctype-decl-missing src/main/java |
| Semgrep | java.lang.security.audit.xxe.documentbuilderfactory-disallow-doctype-decl-false.documentbuilderfactory-disallow-doctype-decl-false | No findings, as expected: this sibling rule flags only an explicit setFeature(disallow-doctype-decl, false). None of the three implementations sets it false (vulnerable/wrong omit it; fixed sets it true), so the -missing rule above is the one that fires.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | java/xxe | Path-problem query (@id java/xxe, CWE-611). Its model treats a JAXP DOM/SAX/StAX parse of tainted input as an XXE sink unless a safe-config sanitiser (disallow-doctype-decl true, or the external-entity features false) is seen, and tracks @RequestBody/request input as a remote source, so it is expected to flag vulnerable and wrong and clear fixed. Reads the request body as a byte[] here, which the model tracks.Checked: github/codeql main; not executed |
| Find Security Bugs (SpotBugs plugin) | XXE_DOCUMENTXML parsing vulnerable to XXE (DocumentBuilder) | Listed pattern for a DocumentBuilder that parses untrusted XML without disabling DTDs/external entities; like the Semgrep rule it reports the sink, not whether the configuration is safe, so expect it on vulnerable and wrong.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7, Spring MVC) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
| javax.xml.parsers DocumentBuilderFactory / JAXP (JDK) | Java 21 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xxe/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs xxe/java-spring runs all three modes and checks the expected results.
Verified
- Toolchain
- openjdk 21.0.12.1
- maven 3.9.16
- springBoot 4.1.1
- junitJupiter 6.0.3
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 4 of 9 tests fail (4 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 9/9 pass; IMPL=vulnerable 4 exploit tests fail (both file-read markers leak via the default factory, both expansions return 128 bytes); IMPL=wrong 2 exploit tests fail (the UTF-16 file-read and UTF-16 expansion slip past the ISO-8859-1 '<!DOCTYPE' blocklist; the UTF-8 pair is blocked).
- Scanners
- Semgrep 1.180.0 from a throwaway venv (../.tools/venv), not a card dependency.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP XML External Entity Prevention Cheat Sheet (Java section)
- Java SE 21 API: DocumentBuilderFactory.setFeature
- Apache Xerces2 Features: disallow-doctype-decl and external entities
- JAXP: XMLConstants.FEATURE_SECURE_PROCESSING
- CWE-611: Improper Restriction of XML External Entity Reference
- CWE-776: Improper Restriction of Recursive Entity References in DTDs ('XML Entity Expansion')
- CAPEC-221: Data Serialization External Entities Blowup
Attack patterns: