| /* |
| * |
| * 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 |
| * |
| * https://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.commons.xml; |
| |
| import static org.apache.commons.xml.AttackTestSupport.LEAKED_MARKER; |
| import static org.apache.commons.xml.AttackTestSupport.inputSource; |
| import static org.apache.commons.xml.AttackTestSupport.resourceUrl; |
| import static org.junit.jupiter.api.Assertions.assertEquals; |
| import static org.junit.jupiter.api.Assertions.assertThrows; |
| import static org.junit.jupiter.api.Assertions.assertTrue; |
| |
| import java.io.StringReader; |
| |
| import javax.xml.parsers.DocumentBuilder; |
| import javax.xml.parsers.DocumentBuilderFactory; |
| import javax.xml.parsers.SAXParserFactory; |
| |
| import org.junit.jupiter.api.Assumptions; |
| import org.junit.jupiter.api.Tag; |
| import org.junit.jupiter.api.Test; |
| import org.w3c.dom.Document; |
| import org.xml.sax.EntityResolver; |
| import org.xml.sax.InputSource; |
| import org.xml.sax.SAXException; |
| import org.xml.sax.XMLReader; |
| import org.xml.sax.helpers.DefaultHandler; |
| |
| /** |
| * Tests that XInclude resolution is denied by default on factories from {@link XmlFactories}, and that callers can |
| * allow-list specific resources via an {@link EntityResolver}. |
| * |
| * <p>Each case is exercised in both {@code parse="xml"} and {@code parse="text"} modes, and for both DOM and SAX |
| * paths. XInclude resolution requires namespace-aware processing; the baseline tests set it explicitly, and the |
| * hardened factory tests rely on the underlying JAXP implementation being namespace-aware enough to recognise elements |
| * in the {@code http://www.w3.org/2001/XInclude} namespace.</p> |
| */ |
| class XIncludeTest { |
| |
| /** Absolute URL of the XML fixture pulled in by {@code parse="xml"} includes; carries {@link AttackTestSupport#LEAKED_MARKER}. */ |
| private static final String REFERENCED_XML = resourceUrl("referenced.xml").toString(); |
| |
| /** Absolute URL of the text fixture pulled in by {@code parse="text"} includes; carries {@link AttackTestSupport#LEAKED_MARKER}. */ |
| private static final String REFERENCED_TEXT = resourceUrl("referenced.txt").toString(); |
| |
| /** Content the allow-list resolver returns for an allowed include; its presence proves the caller's resolver was consulted. */ |
| private static final String RESOLVED_MARKER = "XINCLUDE-RESOLVED-905bbbce-16ee-4a0c-b165-d1f8c663934c"; |
| |
| /** |
| * Allow-lists the two fixture URLs, returning the appropriate in-memory content for each: {@link #RESOLVED_MARKER} |
| * wrapped as XML for {@link #REFERENCED_XML}, and as plain text for {@link #REFERENCED_TEXT}. Anything else returns |
| * {@code null} so the hardening's deny-all floor refuses it. Mirrors a caller allow-listing trusted resources. |
| */ |
| private static final class AllowListResolver implements EntityResolver { |
| |
| @Override |
| public InputSource resolveEntity(final String publicId, final String systemId) { |
| final InputSource source; |
| if (REFERENCED_XML.equals(systemId)) { |
| source = new InputSource(new StringReader("<allowed>" + RESOLVED_MARKER + "</allowed>")); |
| } else if (REFERENCED_TEXT.equals(systemId)) { |
| source = new InputSource(new StringReader(RESOLVED_MARKER)); |
| } else { |
| return null; |
| } |
| source.setPublicId(publicId); |
| source.setSystemId(systemId); |
| return source; |
| } |
| } |
| |
| /** Resolver that resolves nothing, so the hardening's deny-all floor must refuse every lookup and never leak. */ |
| private static final EntityResolver NO_OP_RESOLVER = (publicId, systemId) -> null; |
| |
| /** XML wrapper for xi:include in the given {@code parse} mode referencing {@code href}. */ |
| private static String xiIncludeXml(final String href, final String parseMode) { |
| return "<?xml version=\"1.0\"?>\n" |
| + "<root xmlns:xi=\"http://www.w3.org/2001/XInclude\">\n" |
| + " <xi:include href=\"" + href + "\" parse=\"" + parseMode + "\"/>\n" |
| + "</root>"; |
| } |
| |
| /** |
| * Enables XInclude on the factory under test, skipping the test when the platform refuses. |
| * |
| * <p>On Android{@code setXIncludeAware(true)} always throws {@link UnsupportedOperationException}.</p> |
| */ |
| private static void assumeXIncludeAware(final DocumentBuilderFactory factory) { |
| try { |
| factory.setXIncludeAware(true); |
| } catch (final UnsupportedOperationException e) { |
| Assumptions.abort("XInclude not supported on this platform"); |
| } |
| } |
| |
| /** |
| * Enables XInclude on the factory under test, skipping the test when the platform refuses. |
| * |
| * <p>On Android{@code setXIncludeAware(true)} always throws {@link UnsupportedOperationException}.</p> |
| */ |
| private static void assumeXIncludeAware(final SAXParserFactory factory) { |
| try { |
| factory.setXIncludeAware(true); |
| } catch (final UnsupportedOperationException e) { |
| Assumptions.abort("XInclude not supported on this platform"); |
| } |
| } |
| |
| //region Baseline: unhardened JAXP resolves the include |
| |
| @Test |
| @Tag("dom") |
| void baselineDomLeaksParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final Document doc = factory.newDocumentBuilder().parse(input); |
| final String text = doc.getDocumentElement().getTextContent(); |
| assertEquals(LEAKED_MARKER, text.trim(), |
| "Baseline DOM parse=xml should leak marker; got: " + text); |
| } |
| |
| @Test |
| @Tag("dom") |
| void baselineDomLeaksParseText() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text")); |
| |
| final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final Document doc = factory.newDocumentBuilder().parse(input); |
| final String text = doc.getDocumentElement().getTextContent(); |
| assertTrue(text != null && text.contains(LEAKED_MARKER), |
| "Baseline DOM parse=text should leak marker; got: " + text); |
| } |
| |
| @Test |
| @Tag("sax") |
| void baselineSaxLeaksParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final SAXParserFactory factory = SAXParserFactory.newInstance(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final StringBuilder captured = new StringBuilder(); |
| final XMLReader reader = factory.newSAXParser().getXMLReader(); |
| reader.setContentHandler(new DefaultHandler() { |
| @Override |
| public void characters(final char[] ch, final int start, final int length) { |
| captured.append(ch, start, length); |
| } |
| }); |
| reader.parse(input); |
| assertEquals(LEAKED_MARKER, captured.toString().trim(), |
| "Baseline SAX parse=xml should leak marker; got: " + captured); |
| } |
| |
| @Test |
| @Tag("sax") |
| void baselineSaxLeaksParseText() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text")); |
| |
| final SAXParserFactory factory = SAXParserFactory.newInstance(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final StringBuilder captured = new StringBuilder(); |
| final XMLReader reader = factory.newSAXParser().getXMLReader(); |
| reader.setContentHandler(new DefaultHandler() { |
| @Override |
| public void characters(final char[] ch, final int start, final int length) { |
| captured.append(ch, start, length); |
| } |
| }); |
| reader.parse(input); |
| assertTrue(captured.toString().contains(LEAKED_MARKER), |
| "Baseline SAX parse=text should leak marker; got: " + captured); |
| } |
| |
| //endregion |
| |
| //region Hardened factory: fails closed (throws) |
| |
| @Test |
| @Tag("dom") |
| void hardenedDomBlocksParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final DocumentBuilderFactory factory = XmlFactories.newDocumentBuilderFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| assertThrows(SAXException.class, () -> { |
| final DocumentBuilder builder = factory.newDocumentBuilder(); |
| builder.parse(input); |
| }, "Hardened DOM parse=xml should throw"); |
| } |
| |
| @Test |
| @Tag("dom") |
| void hardenedDomBlocksParseText() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text")); |
| |
| final DocumentBuilderFactory factory = XmlFactories.newDocumentBuilderFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| assertThrows(SAXException.class, () -> { |
| final DocumentBuilder builder = factory.newDocumentBuilder(); |
| builder.parse(input); |
| }, "Hardened DOM parse=text should throw"); |
| } |
| |
| @Test |
| @Tag("sax") |
| void hardenedSaxBlocksParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final SAXParserFactory factory = XmlFactories.newSAXParserFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| assertThrows(SAXException.class, () -> { |
| final XMLReader reader = factory.newSAXParser().getXMLReader(); |
| reader.parse(input); |
| }, "Hardened SAX parse=xml should throw"); |
| } |
| |
| @Test |
| @Tag("sax") |
| void hardenedSaxBlocksParseText() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text")); |
| |
| final SAXParserFactory factory = XmlFactories.newSAXParserFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| assertThrows(SAXException.class, () -> { |
| final XMLReader reader = factory.newSAXParser().getXMLReader(); |
| reader.parse(input); |
| }, "Hardened SAX parse=text should throw"); |
| } |
| |
| //endregion |
| |
| //region Hardened factory + allow-list resolver: allowed href works, non-allowed throws |
| |
| @Test |
| @Tag("dom") |
| void hardenedDomWithAllowListResolvesParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final DocumentBuilderFactory factory = XmlFactories.newDocumentBuilderFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final DocumentBuilder builder = factory.newDocumentBuilder(); |
| builder.setEntityResolver(new AllowListResolver()); |
| final Document doc = builder.parse(input); |
| assertEquals(RESOLVED_MARKER, doc.getDocumentElement().getTextContent().trim(), |
| "DOM parse=xml with allow-list should resolve to the resolver's content"); |
| } |
| |
| @Test |
| @Tag("dom") |
| void hardenedDomWithAllowListResolvesParseText() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text")); |
| |
| final DocumentBuilderFactory factory = XmlFactories.newDocumentBuilderFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final DocumentBuilder builder = factory.newDocumentBuilder(); |
| builder.setEntityResolver(new AllowListResolver()); |
| final Document doc = builder.parse(input); |
| assertEquals(RESOLVED_MARKER, doc.getDocumentElement().getTextContent().trim(), |
| "DOM parse=text with allow-list should resolve to the resolver's content"); |
| } |
| |
| @Test |
| @Tag("dom") |
| void hardenedDomNullResolverDoesNotLeak() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final DocumentBuilderFactory factory = XmlFactories.newDocumentBuilderFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final DocumentBuilder builder = factory.newDocumentBuilder(); |
| builder.setEntityResolver(NO_OP_RESOLVER); |
| assertThrows(SAXException.class, () -> builder.parse(input), |
| "a resolver that returns null must not leak: the deny-all floor blocks the real href"); |
| } |
| |
| @Test |
| @Tag("sax") |
| void hardenedSaxWithAllowListResolvesParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final SAXParserFactory factory = XmlFactories.newSAXParserFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final XMLReader reader = factory.newSAXParser().getXMLReader(); |
| reader.setEntityResolver(new AllowListResolver()); |
| final StringBuilder captured = new StringBuilder(); |
| reader.setContentHandler(new DefaultHandler() { |
| @Override |
| public void characters(final char[] ch, final int start, final int length) { |
| captured.append(ch, start, length); |
| } |
| }); |
| reader.parse(input); |
| assertEquals(RESOLVED_MARKER, captured.toString().trim(), |
| "SAX parse=xml with allow-list should resolve to the resolver's content"); |
| } |
| |
| @Test |
| @Tag("sax") |
| void hardenedSaxWithAllowListResolvesParseText() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text")); |
| |
| final SAXParserFactory factory = XmlFactories.newSAXParserFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final XMLReader reader = factory.newSAXParser().getXMLReader(); |
| reader.setEntityResolver(new AllowListResolver()); |
| final StringBuilder captured = new StringBuilder(); |
| reader.setContentHandler(new DefaultHandler() { |
| @Override |
| public void characters(final char[] ch, final int start, final int length) { |
| captured.append(ch, start, length); |
| } |
| }); |
| reader.parse(input); |
| assertEquals(RESOLVED_MARKER, captured.toString().trim(), |
| "SAX parse=text with allow-list should resolve to the resolver's content"); |
| } |
| |
| @Test |
| @Tag("sax") |
| void hardenedSaxNullResolverDoesNotLeak() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final SAXParserFactory factory = XmlFactories.newSAXParserFactory(); |
| factory.setNamespaceAware(true); |
| assumeXIncludeAware(factory); |
| final XMLReader reader = factory.newSAXParser().getXMLReader(); |
| reader.setEntityResolver(NO_OP_RESOLVER); |
| assertThrows(SAXException.class, () -> reader.parse(input), |
| "a resolver that returns null must not leak: the deny-all floor blocks the real href"); |
| } |
| |
| //endregion |
| |
| //region harden(XMLReader): reader with XInclude enabled before hardening is blocked |
| |
| @Test |
| @Tag("sax") |
| void hardenReaderBlocksParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| // Reader from an unhardened factory that already has XInclude enabled |
| final SAXParserFactory unhardenedFactory = SAXParserFactory.newInstance(); |
| unhardenedFactory.setNamespaceAware(true); |
| assumeXIncludeAware(unhardenedFactory); |
| final XMLReader reader = XmlFactories.harden(unhardenedFactory.newSAXParser().getXMLReader()); |
| assertThrows(SAXException.class, () -> reader.parse(input), |
| "harden(reader) should block XInclude parse=xml on reader with XInclude already enabled"); |
| } |
| |
| @Test |
| @Tag("sax") |
| void hardenReaderBlocksParseText() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_TEXT, "text")); |
| |
| final SAXParserFactory unhardenedFactory = SAXParserFactory.newInstance(); |
| unhardenedFactory.setNamespaceAware(true); |
| assumeXIncludeAware(unhardenedFactory); |
| final XMLReader reader = XmlFactories.harden(unhardenedFactory.newSAXParser().getXMLReader()); |
| assertThrows(SAXException.class, () -> reader.parse(input), |
| "harden(reader) should block XInclude parse=text on reader with XInclude already enabled"); |
| } |
| |
| @Test |
| @Tag("sax") |
| void hardenReaderAllowListResolvesParseXml() throws Exception { |
| final InputSource input = inputSource(xiIncludeXml(REFERENCED_XML, "xml")); |
| |
| final SAXParserFactory unhardenedFactory = SAXParserFactory.newInstance(); |
| unhardenedFactory.setNamespaceAware(true); |
| assumeXIncludeAware(unhardenedFactory); |
| final XMLReader reader = XmlFactories.harden(unhardenedFactory.newSAXParser().getXMLReader()); |
| reader.setEntityResolver(new AllowListResolver()); |
| final StringBuilder captured = new StringBuilder(); |
| reader.setContentHandler(new DefaultHandler() { |
| @Override |
| public void characters(final char[] ch, final int start, final int length) { |
| captured.append(ch, start, length); |
| } |
| }); |
| reader.parse(input); |
| assertEquals(RESOLVED_MARKER, captured.toString().trim(), |
| "harden(reader) + allow-list should resolve to the resolver's content on a reader with XInclude already enabled"); |
| } |
| |
| //endregion |
| } |