/*
* 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 + ""
+ "";
}
}