/* * 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.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.parsers.DocumentBuilderFactory; 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.w3c.dom.Document; import jakarta.xml.soap.MessageFactory; import jakarta.xml.soap.MimeHeaders; import jakarta.xml.soap.SOAPConstants; import jakarta.xml.soap.SOAPException; import jakarta.xml.soap.SOAPMessage; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; /** * Pins the entity-expansion behaviour of the SAAJ implementation the DSMLv2 * gateway parses requests with. *

* {@link DSMLServlet#doPost} hands the request body straight to * {@code MessageFactory.createMessage(MimeHeaders, InputStream)}, which builds * its own parser: the servlet cannot configure it, and the SAAJ API offers no * way to assert that it is safe. The protection therefore comes from the * implementation on the classpath, and swapping that implementation would * remove it silently - hence this test (GHSA-jr68-5c58-j463). *

* 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 SoapEntityExpansionTestCase extends ForgeRockTestCase { private static final String CANARY = "s3cr3t-canary-value"; /** Literal text sitting next to the entity reference in every payload. */ private static final String MARKER = "attribute-value-marker"; private static final String SOAP_11_CONTENT_TYPE = "text/xml; charset=utf-8"; private static final String SOAP_12_CONTENT_TYPE = "application/soap+xml; charset=utf-8"; private Path tempDir; /** File an XXE payload tries to read. */ private String canaryUri; /** External DTD subset declaring an entity which reads the canary file. */ private String externalDtdUri; @BeforeClass public void createPayloadFiles() throws IOException { tempDir = Files.createTempDirectory("dsml-xxe-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()); } } } @DataProvider public Object[][] entityPayloads() { return new Object[][] { { "SOAP 1.1, external general entity", SOAPConstants.SOAP_1_1_PROTOCOL, SOAP_11_CONTENT_TYPE, "" + "]>" + soap11Envelope("&xxe;") }, { "SOAP 1.1, external DTD subset", SOAPConstants.SOAP_1_1_PROTOCOL, SOAP_11_CONTENT_TYPE, "" + "" + soap11Envelope("&xxe;") }, { "SOAP 1.2, external general entity", SOAPConstants.SOAP_1_2_PROTOCOL, SOAP_12_CONTENT_TYPE, "" + "]>" + soap12Envelope("&xxe;") }, { "SOAP 1.2, external DTD subset", SOAPConstants.SOAP_1_2_PROTOCOL, SOAP_12_CONTENT_TYPE, "" + "" + soap12Envelope("&xxe;") }, }; } /** * A request declaring an external entity must either be rejected outright, * or be parsed without the entity ever being resolved. Both outcomes keep * the file contents out of the batch request; anything else is an XXE. */ @Test(dataProvider = "entityPayloads") public void testExternalEntityIsNeverExpanded(String label, String protocol, String contentType, String request) throws Exception { String body; try { body = parseBody(protocol, contentType, request); } catch (SOAPException e) { // Rejected before any entity could be resolved. return; } // The implementation parsed the request instead of rejecting it. The rest // of this method is then the assertion that matters, so first make sure it // is looking at a body that really was parsed - an empty one would satisfy // the check below without proving anything. assertTrue(body.contains(MARKER), label + ": the request was accepted but its body is not there: " + body); assertFalse(body.contains(CANARY), label + ": the external entity was expanded into the request body: " + body); } /** The rejection above must not come from the gateway refusing plain requests. */ @Test public void testRequestWithoutDoctypeIsParsed() throws Exception { String body = parseBody(SOAPConstants.SOAP_1_1_PROTOCOL, SOAP_11_CONTENT_TYPE, "" + soap11Envelope("a plain attribute value")); assertTrue(body.contains("a plain attribute value"), "unexpected body: " + body); } /** * The payloads above are only worth anything if they would actually leak the * file through a parser that is not protected - otherwise the whole test case * could keep passing against an implementation that stopped protecting * anything. This is that control: the very same request, through a plain * DocumentBuilder, must hand the file contents over. */ @Test public void testPayloadLeaksThroughAnUnprotectedParser() throws Exception { String request = null; for (Object[] payload : entityPayloads()) { if (((String) payload[0]).contains("external general entity")) { request = (String) payload[3]; break; } } DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setNamespaceAware(true); Document document = factory.newDocumentBuilder() .parse(new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8))); assertTrue(document.getDocumentElement().getTextContent().contains(CANARY), "the XXE payload no longer reads the file even without any protection, " + "so this test case proves nothing anymore"); } /** Parses a request the way {@link DSMLServlet#doPost} does, and returns the body as text. */ private String parseBody(String protocol, String contentType, String request) throws Exception { MimeHeaders mimeHeaders = new MimeHeaders(); mimeHeaders.addHeader("Content-Type", contentType); SOAPMessage message = MessageFactory.newInstance(protocol).createMessage(mimeHeaders, new ByteArrayInputStream(request.getBytes(StandardCharsets.UTF_8))); // SAAJ parses lazily, the body is only built here. return message.getSOAPBody().getTextContent(); } private static String soap11Envelope(String dn) { return "" + "" + batchRequest(dn) + ""; } private static String soap12Envelope(String dn) { return "" + "" + batchRequest(dn) + ""; } /** * A batch request carrying the given text as an attribute value, so that an * expanded entity ends up in the text content of the SOAP body. The value is * prefixed with {@link #MARKER}, which tells a body that was parsed without * expanding the entity from a body that was never parsed at all. */ private static String batchRequest(String value) { return "" + "" + "" + MARKER + value + "" + ""; } }