1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 package org.apache.commons.xml.secure;
19
20 import static org.apache.commons.xml.secure.AttackTestSupport.assertParseFails;
21 import static org.apache.commons.xml.secure.AttackTestSupport.assertParseSucceeds;
22 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
23 import static org.junit.jupiter.api.Assertions.assertFalse;
24 import static org.junit.jupiter.api.Assertions.assertSame;
25 import static org.junit.jupiter.api.Assertions.assertTrue;
26
27 import java.io.IOException;
28 import java.io.StringWriter;
29 import java.net.URL;
30
31 import javax.xml.XMLConstants;
32 import javax.xml.parsers.DocumentBuilder;
33 import javax.xml.parsers.DocumentBuilderFactory;
34 import javax.xml.parsers.SAXParser;
35 import javax.xml.parsers.SAXParserFactory;
36 import javax.xml.stream.XMLInputFactory;
37 import javax.xml.stream.XMLResolver;
38 import javax.xml.stream.XMLStreamException;
39 import javax.xml.transform.TransformerFactory;
40 import javax.xml.transform.URIResolver;
41 import javax.xml.transform.stream.StreamResult;
42 import javax.xml.validation.SchemaFactory;
43
44 import org.junit.jupiter.api.Assumptions;
45 import org.junit.jupiter.api.Tag;
46 import org.junit.jupiter.api.Test;
47 import org.w3c.dom.Document;
48 import org.w3c.dom.bootstrap.DOMImplementationRegistry;
49 import org.w3c.dom.ls.DOMImplementationLS;
50 import org.w3c.dom.ls.LSInput;
51 import org.w3c.dom.ls.LSResourceResolver;
52 import org.xml.sax.EntityResolver;
53 import org.xml.sax.InputSource;
54 import org.xml.sax.SAXException;
55 import org.xml.sax.XMLReader;
56
57
58
59
60
61
62
63
64
65
66
67
68 class EntityResolverFloorTest {
69
70
71
72
73 private static final String ALLOWED = AttackTestSupport.resourceUrl("referenced.txt").toString();
74
75
76
77
78 private static final String UNLISTED = AttackTestSupport.resourceUrl("referenced.xml").toString();
79
80
81
82
83 private static final EntityResolver ENTITY_ALLOW_LIST = (publicId, systemId) ->
84 ALLOWED.equals(systemId) ? new InputSource(new URL(systemId).openStream()) : null;
85
86
87
88
89
90
91
92 private static final EntityResolver RESOLVE_ALL = (publicId, systemId) -> {
93 final InputSource source = new InputSource(new URL(systemId).openStream());
94 source.setSystemId(systemId);
95 return source;
96 };
97
98
99
100
101 private static final String XINCLUDE_HOST = AttackTestSupport.resourceUrl("with-xinclude.xml").toString();
102
103
104
105
106 private static final XMLResolver STAX_ALLOW_LIST = (publicID, systemID, baseURI, namespace) -> {
107 if (!ALLOWED.equals(systemID)) {
108 return null;
109 }
110 try {
111 return new URL(systemID).openStream();
112 } catch (final IOException e) {
113 throw new XMLStreamException(e);
114 }
115 };
116
117
118
119
120 private static final String ALLOWED_SCHEMA = AttackTestSupport.resourceUrl("included.xsd").toString();
121
122
123
124
125 private static final LSResourceResolver SCHEMA_ALLOW_LIST = (type, namespaceURI, publicId, systemId, baseURI) ->
126 systemId != null && systemId.endsWith("included.xsd") ? lsInput(ALLOWED_SCHEMA) : null;
127
128
129
130
131 private static final URIResolver XSL_ALLOW_LIST = (href, base) ->
132 href != null && href.endsWith("included.xsl") ? AttackTestSupport.resourceSource("included.xsl") : null;
133
134 private static String entityPayload(final String entitySystemId) {
135 return "<?xml version=\"1.0\"?>\n"
136 + "<!DOCTYPE root [\n <!ENTITY xxe SYSTEM \"" + entitySystemId + "\">\n]>\n"
137 + "<root>&xxe;</root>";
138 }
139
140 private static XMLInputFactory externalEntityStaxFactory() {
141 final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
142 factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, true);
143 factory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, true);
144 return factory;
145 }
146
147
148
149
150
151 private static LSInput identifierOnlyLsInput(final String systemId) {
152 return assertDoesNotThrow(() -> {
153 final DOMImplementationLS ls = (DOMImplementationLS) DOMImplementationRegistry.newInstance().getDOMImplementation("LS");
154 final LSInput input = ls.createLSInput();
155 input.setSystemId(systemId);
156 return input;
157 }, "Failed to build identifier-only LSInput for " + systemId);
158 }
159
160 private static LSInput lsInput(final String systemId) {
161 return assertDoesNotThrow(() -> {
162 final LSInput input = identifierOnlyLsInput(systemId);
163 input.setByteStream(new URL(systemId).openStream());
164 return input;
165 }, "Failed to build LSInput for " + systemId);
166 }
167
168 private static DocumentBuilder secureBuilder() throws Exception {
169 final DocumentBuilder builder = SecureDocumentBuilderFactory.newInstance().newDocumentBuilder();
170 builder.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
171 return builder;
172 }
173
174
175
176
177
178
179
180 private static TransformerFactory secureTransformerFactory() {
181 final TransformerFactory factory = SecureTransformerFactory.newInstance();
182 factory.setErrorListener(AttackTestSupport.STRICT_REPORTER);
183 return factory;
184 }
185
186 private static XMLReader secureXMLReader() throws Exception {
187 final XMLReader reader = SecureSAXParserFactory.newInstance().newSAXParser().getXMLReader();
188 reader.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
189 return reader;
190 }
191
192 private static DocumentBuilder xIncludeAwareBuilder() throws Exception {
193 final DocumentBuilderFactory factory = SecureDocumentBuilderFactory.newInstance();
194 factory.setNamespaceAware(true);
195 AttackTestSupport.assumeDoesNotThrow(() -> factory.setXIncludeAware(true));
196 final DocumentBuilder builder = factory.newDocumentBuilder();
197 builder.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
198 return builder;
199 }
200
201 private static XMLReader xIncludeAwareReader() throws Exception {
202 final SAXParserFactory factory = SecureSAXParserFactory.newInstance();
203 factory.setNamespaceAware(true);
204 AttackTestSupport.assumeDoesNotThrow(() -> factory.setXIncludeAware(true));
205 final XMLReader reader = factory.newSAXParser().getXMLReader();
206 reader.setErrorHandler(AttackTestSupport.STRICT_REPORTER);
207 return reader;
208 }
209
210 @Test
211 @Tag("dom")
212 void domDoesNotLeakUnlisted() throws Exception {
213 Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, "platform DOM does not resolve user-defined entities");
214 final DocumentBuilder builder = secureBuilder();
215 builder.setEntityResolver(ENTITY_ALLOW_LIST);
216
217
218 try {
219 final Document doc = builder.parse(AttackTestSupport.inputSource(entityPayload(UNLISTED)));
220 assertFalse(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER), "unlisted external entity leaked into the DOM");
221 } catch (final SAXException blocked) {
222
223 }
224 }
225
226 @Test
227 @Tag("dom")
228 void domResolvesAllowListed() throws Exception {
229 Assumptions.assumeTrue(AttackTestSupport.DOM_RESOLVES_INTERNAL_ENTITIES, "platform DOM does not resolve user-defined entities");
230 final DocumentBuilder builder = secureBuilder();
231 builder.setEntityResolver(ENTITY_ALLOW_LIST);
232 final Document doc = builder.parse(AttackTestSupport.inputSource(entityPayload(ALLOWED)));
233 assertTrue(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER),
234 "allow-listed external entity should resolve through the caller's resolver");
235 }
236
237 @Test
238 @Tag("dom")
239 void domResolvesRelativeXIncludeSibling() throws Exception {
240 final DocumentBuilder builder = xIncludeAwareBuilder();
241 builder.setEntityResolver(RESOLVE_ALL);
242 final Document doc = builder.parse(XINCLUDE_HOST);
243 assertTrue(doc.getDocumentElement().getTextContent().contains(AttackTestSupport.LEAKED_MARKER),
244 "relative XInclude sibling should resolve through the caller's resolver after the floor absolutizes the href");
245 }
246
247 @Test
248 @Tag("sax")
249 void saxParseWithHandlerDoesNotBypass() throws Exception {
250
251
252 final SAXParser parser = SecureSAXParserFactory.newInstance().newSAXParser();
253 final StringBuilder text = new StringBuilder();
254 try {
255 parser.parse(AttackTestSupport.inputSource(entityPayload(ALLOWED)), AttackTestSupport.capturingHandler(text));
256 } catch (final SAXException e) {
257 return;
258 }
259 assertFalse(text.toString().contains(AttackTestSupport.LEAKED_MARKER), "parse(source, handler) leaked the external entity:\n" + text);
260 }
261
262 @Test
263 @Tag("sax")
264 void saxReaderDoesNotLeakUnlisted() throws Exception {
265 final XMLReader reader = secureXMLReader();
266 reader.setEntityResolver(ENTITY_ALLOW_LIST);
267
268 final String text;
269 try {
270 text = AttackTestSupport.captureCharacters(reader, entityPayload(UNLISTED));
271 } catch (final SAXException blocked) {
272 return;
273 }
274 assertFalse(text.contains(AttackTestSupport.LEAKED_MARKER), "unlisted external entity leaked:\n" + text);
275 }
276
277 @Test
278 @Tag("sax")
279 void saxReaderResolvesAllowListed() throws Exception {
280 final XMLReader reader = secureXMLReader();
281 reader.setEntityResolver(ENTITY_ALLOW_LIST);
282 final String text = AttackTestSupport.captureCharacters(reader, entityPayload(ALLOWED));
283 assertTrue(text.contains(AttackTestSupport.LEAKED_MARKER),
284 "allow-listed external entity should resolve through the caller's resolver");
285 }
286
287 @Test
288 @Tag("sax")
289 void saxResolvesRelativeXIncludeSibling() throws Exception {
290 final XMLReader reader = xIncludeAwareReader();
291 reader.setEntityResolver(RESOLVE_ALL);
292 final String text = AttackTestSupport.captureCharacters(reader, new InputSource(XINCLUDE_HOST));
293 assertTrue(text.contains(AttackTestSupport.LEAKED_MARKER),
294 "relative XInclude sibling should resolve through the caller's resolver after the floor absolutizes the href");
295 }
296
297 @Test
298 @Tag("schema")
299 void schemaDeniesUnlisted() {
300 assertParseFails(() -> {
301 final SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
302 factory.setResourceResolver((type, namespaceURI, publicId, systemId, baseURI) -> null);
303 factory.newSchema(AttackTestSupport.resourceSource("with-import.xsd"));
304 }, "Schema import", SAXException.class, SecurityException.class);
305 }
306
307 @Test
308 @Tag("schema")
309 void schemaFetchesIdentifierOnlyOptIn() {
310
311 assertParseSucceeds(() -> {
312 final SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
313 factory.setResourceResolver((type, namespaceURI, publicId, systemId, baseURI) ->
314 systemId != null && systemId.endsWith("included.xsd") ? identifierOnlyLsInput(ALLOWED_SCHEMA) : null);
315 factory.newSchema(AttackTestSupport.resourceSource("with-import.xsd"));
316 }, "Schema import via identifier-only LSInput");
317 }
318
319 @Test
320 @Tag("schema")
321 void schemaResolvesAllowListed() {
322
323 assertParseSucceeds(() -> {
324 final SchemaFactory factory = SecureSchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
325 factory.setResourceResolver(SCHEMA_ALLOW_LIST);
326 factory.newSchema(AttackTestSupport.resourceSource("with-import.xsd"));
327 }, "Schema import via caller resolver");
328 }
329
330 @Test
331 @Tag("stax")
332 void staxCallerCannotRemoveFloor() throws Exception {
333
334 final XMLInputFactory factory = externalEntityStaxFactory();
335 factory.setXMLResolver((publicID, systemID, baseURI, namespace) -> null);
336 try {
337 assertFalse(AttackTestSupport.captureStaxEventText(factory, entityPayload(ALLOWED)).contains(AttackTestSupport.LEAKED_MARKER),
338 "floor was bypassed and the entity leaked");
339 } catch (final XMLStreamException blocked) {
340
341 }
342 }
343
344 @Test
345 @Tag("stax")
346 void staxDoesNotLeakUnlisted() throws Exception {
347 final XMLInputFactory factory = externalEntityStaxFactory();
348 factory.setXMLResolver(STAX_ALLOW_LIST);
349
350 try {
351 assertFalse(AttackTestSupport.captureStaxEventText(factory, entityPayload(UNLISTED)).contains(AttackTestSupport.LEAKED_MARKER),
352 "unlisted external entity leaked");
353 } catch (final XMLStreamException blocked) {
354
355 }
356 }
357
358 @Test
359 @Tag("stax")
360 void staxGetXMLResolverReportsCallerUnwrapped() {
361 final XMLInputFactory factory = SecureXMLInputFactory.newInstance();
362 final XMLResolver caller = (publicID, systemID, baseURI, namespace) -> null;
363 factory.setXMLResolver(caller);
364 assertSame(caller, factory.getXMLResolver(), "getXMLResolver should report the caller's resolver, not the floor wrapper");
365 }
366
367 @Test
368 @Tag("stax")
369 void staxResolvesAllowListed() throws Exception {
370 final XMLInputFactory factory = externalEntityStaxFactory();
371 factory.setXMLResolver(STAX_ALLOW_LIST);
372 assertTrue(AttackTestSupport.captureStaxEventText(factory, entityPayload(ALLOWED)).contains(AttackTestSupport.LEAKED_MARKER),
373 "allow-listed external entity should resolve through the caller's resolver");
374 }
375
376 @Test
377 @Tag("trax")
378 void transformerDoesNotLeakUnlisted() throws Exception {
379 final TransformerFactory factory = secureTransformerFactory();
380 factory.setURIResolver((href, base) -> null);
381
382
383 final StringWriter sink = new StringWriter();
384 factory.newTemplates(AttackTestSupport.resourceSource("with-import.xsl")).newTransformer().transform(AttackTestSupport.streamSource("<root/>"),
385 new StreamResult(sink));
386 assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), "unlisted stylesheet import leaked");
387 }
388
389 @Test
390 @Tag("trax")
391 void transformerParsesOptedInDocumentSecured() throws Exception {
392
393 final TransformerFactory factory = secureTransformerFactory();
394 factory.setURIResolver(
395 (href, base) -> href != null && href.endsWith("referenced.xml") ? AttackTestSupport.resourceSource("referenced-with-entity.xml") : null);
396
397 final StringWriter sink = new StringWriter();
398 factory.newTemplates(AttackTestSupport.resourceSource("with-document.xsl")).newTransformer().transform(AttackTestSupport.streamSource("<root/>"),
399 new StreamResult(sink));
400 assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), "opted-in document() resource leaked its external entity");
401 }
402
403 @Test
404 @Tag("trax")
405 void transformerParsesOptedInImportSecured() throws Exception {
406
407 final TransformerFactory factory = secureTransformerFactory();
408 factory.setURIResolver(
409 (href, base) -> href != null && href.endsWith("included.xsl") ? AttackTestSupport.resourceSource("included-with-entity.xsl") : null);
410
411
412 final StringWriter sink = new StringWriter();
413 factory.newTemplates(AttackTestSupport.resourceSource("with-import.xsl")).newTransformer().transform(AttackTestSupport.streamSource("<root/>"),
414 new StreamResult(sink));
415 assertFalse(sink.toString().contains(AttackTestSupport.LEAKED_MARKER), "opted-in stylesheet import leaked its external entity");
416 }
417
418 @Test
419 @Tag("trax")
420 void transformerResolvesAllowListed() {
421
422 final TransformerFactory factory = secureTransformerFactory();
423 factory.setURIResolver(XSL_ALLOW_LIST);
424 assertParseSucceeds(() -> factory.newTemplates(AttackTestSupport.resourceSource("with-import.xsl")), "Stylesheet import via caller resolver");
425 }
426 }