/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2026 3A Systems, LLC. */ package org.opends.dsml.protocol; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; import java.util.stream.Stream; import javax.xml.XMLConstants; import org.forgerock.testng.ForgeRockTestCase; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.XMLReader; import org.xml.sax.helpers.DefaultHandler; import jakarta.xml.bind.JAXBElement; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; /** * Tests the XML parsing the gateway configures itself, as opposed to the * parser SAAJ builds internally (which {@link SoapEntityExpansionTestCase} * covers). *

* {@code DSMLServlet.createSafeXMLReader()} hardens its reader through * {@code safeSetFeature}, which logs and carries on when a feature is not * supported by the parser in use - so a parser change, or a feature name * change, would disarm it without failing anything. That reader parses * untrusted input: when SAAJ refuses a request, the gateway hands the very same * body to it a second time to recover the requestID * ({@code createXMLParsingErrorResponse}). *

* The tests only ever reference {@code file:} URIs under a temporary * directory, so they perform no network or DNS I/O. */ @SuppressWarnings("javadoc") @Test(groups = { "precommit", "dsml" }) public class DSMLXmlParsingTestCase extends ForgeRockTestCase { private static final String CANARY = "s3cr3t-canary-value"; private static final String DISALLOW_DOCTYPE = "http://apache.org/xml/features/disallow-doctype-decl"; private static final String EXTERNAL_GENERAL_ENTITIES = "http://xml.org/sax/features/external-general-entities"; private static final String EXTERNAL_PARAMETER_ENTITIES = "http://xml.org/sax/features/external-parameter-entities"; private Path tempDir; private String canaryUri; private String externalDtdUri; @BeforeClass public void createPayloadFiles() throws IOException { tempDir = Files.createTempDirectory("dsml-parser-test"); Path canary = Files.write(tempDir.resolve("canary.txt"), CANARY.getBytes(StandardCharsets.UTF_8)); canaryUri = canary.toUri().toASCIIString(); Path dtd = Files.write(tempDir.resolve("canary.dtd"), ("").getBytes(StandardCharsets.UTF_8)); externalDtdUri = dtd.toUri().toASCIIString(); } @AfterClass public void deletePayloadFiles() throws IOException { if (tempDir != null) { try (Stream paths = Files.walk(tempDir)) { paths.sorted(Comparator.reverseOrder()).forEach(path -> path.toFile().delete()); } } } /** * The features the reader is hardened with must actually be in effect, not * merely have been requested. */ @Test public void testSafeXmlReaderIsHardened() throws Exception { XMLReader xmlReader = new DSMLServlet().createSafeXMLReader(); assertTrue(xmlReader.getFeature(XMLConstants.FEATURE_SECURE_PROCESSING), "secure processing is not in effect"); assertTrue(xmlReader.getFeature(DISALLOW_DOCTYPE), "a DOCTYPE declaration would be accepted"); assertFalse(xmlReader.getFeature(EXTERNAL_GENERAL_ENTITIES), "external general entities would be resolved"); assertFalse(xmlReader.getFeature(EXTERNAL_PARAMETER_ENTITIES), "external parameter entities would be resolved"); } @DataProvider public Object[][] entityPayloads() { return new Object[][] { { "external general entity", "" + "]>" + batchRequest("&xxe;") }, { "external DTD subset", "" + "" + batchRequest("&xxe;") }, }; } /** * A DOCTYPE is refused outright; should a future parser accept one, the file * contents must still not reach the parsed document. */ @Test(dataProvider = "entityPayloads") public void testSafeXmlReaderNeverExpandsAnExternalEntity(String label, String request) throws Exception { XMLReader xmlReader = new DSMLServlet().createSafeXMLReader(); TextCollector collector = new TextCollector(); xmlReader.setContentHandler(collector); try { xmlReader.parse(new InputSource(asStream(request))); } catch (SAXException e) { // Refused, which is what disallow-doctype-decl is there for. return; } assertFalse(collector.text.toString().contains(CANARY), label + ": the external entity was expanded: " + collector.text); } /** A well-formed request must still parse, otherwise the test above is empty. */ @Test public void testSafeXmlReaderParsesAPlainRequest() throws Exception { XMLReader xmlReader = new DSMLServlet().createSafeXMLReader(); TextCollector collector = new TextCollector(); xmlReader.setContentHandler(collector); xmlReader.parse(new InputSource(asStream(batchRequest("a plain attribute value")))); assertEquals(collector.requestID, "42"); assertTrue(collector.text.toString().contains("a plain attribute value"), "unexpected content: " + collector.text); } /** * The recovery path the servlet takes when SAAJ has refused the request: the * body is parsed a second time, and that second parse must go through the * hardened reader rather than reintroduce the entity expansion. */ @Test(dataProvider = "entityPayloads") public void testXmlParsingErrorResponseDoesNotExpandEntities(String label, String request) throws Exception { ObjectFactory objFactory = new ObjectFactory(); BatchResponse batchResponse = objFactory.createBatchResponse(); InputStream is = asStream(request); JAXBElement response = new DSMLServlet() .createXMLParsingErrorResponse(is, objFactory, batchResponse, "parser error"); assertFalse(String.valueOf(response.getValue().getMessage()).contains(CANARY), label + ": the error response carries the file contents"); assertFalse(String.valueOf(batchResponse.getRequestID()).contains(CANARY), label + ": the requestID carries the file contents"); } /** The same path, on a request the parser accepts: the requestID is recovered. */ @Test public void testXmlParsingErrorResponseRecoversTheRequestID() throws Exception { ObjectFactory objFactory = new ObjectFactory(); BatchResponse batchResponse = objFactory.createBatchResponse(); JAXBElement response = new DSMLServlet().createXMLParsingErrorResponse( asStream(batchRequest("a plain attribute value")), objFactory, batchResponse, "parser error"); assertEquals(batchResponse.getRequestID(), "42"); assertEquals(response.getValue().getMessage(), "parser error"); } /** {@code createXMLParsingErrorResponse} rewinds the stream, so it must support it. */ private InputStream asStream(String request) { return new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8)); } private static String batchRequest(String value) { return "" + "" + "" + value + "" + ""; } /** Collects the character data of a parsed document, and the requestID. */ private static final class TextCollector extends DefaultHandler { private final StringBuilder text = new StringBuilder(); private String requestID; @Override public void startElement(String uri, String localName, String qName, Attributes attributes) { if (requestID == null && "batchRequest".equals(localName)) { requestID = attributes.getValue("requestID"); } } @Override public void characters(char[] ch, int start, int length) { text.append(ch, start, length); } } }