GHSA-jr68-5c58-j463 DSMLv2 gateway changes XML data binding of other applications in the same JVM
9 files modified
3 files added
| | |
| | | <scope>provided</scope> |
| | | </dependency> |
| | | |
| | | <!-- |
| | | SAAJ, for parsing and building the SOAP envelope. The servlet uses no |
| | | JAX-WS API, so it depends on the SAAJ API and its implementation |
| | | rather than on the com.sun.xml.ws:jaxws-ri aggregator, which would |
| | | also drag the XJC/JXC tools, the documentation and samples archives |
| | | and - worse - EclipseLink MOXy into WEB-INF/lib. MOXy registers |
| | | itself as a Jakarta XML Binding provider, so with it on the classpath |
| | | the provider serving the DSMLv2 bindings is decided by the order the |
| | | container's classloader happens to use (GHSA-jr68-5c58-j463). |
| | | --> |
| | | <dependency> |
| | | <groupId>com.sun.xml.ws</groupId> |
| | | <artifactId>jaxws-ri</artifactId> |
| | | <version>4.0.3</version> |
| | | <type>pom</type> |
| | | <groupId>jakarta.xml.soap</groupId> |
| | | <artifactId>jakarta.xml.soap-api</artifactId> |
| | | <version>3.0.2</version> |
| | | </dependency> |
| | | |
| | | <dependency> |
| | | <groupId>com.sun.xml.messaging.saaj</groupId> |
| | | <artifactId>saaj-impl</artifactId> |
| | | <version>3.0.4</version> |
| | | <scope>runtime</scope> |
| | | </dependency> |
| | | |
| | | <!-- |
| | | Exactly one Jakarta XML Binding provider must end up in WEB-INF/lib: |
| | | the provider is resolved through the ServiceLoader, so a second one |
| | | would hand the choice back to the container's classloader ordering. |
| | | JaxbProviderTestCase fails the build if a dependency reintroduces one. |
| | | The API version is kept aligned with the runtime: 3.0.x pulls a second |
| | | jakarta.activation implementation, and its jaxb.properties lookup - a |
| | | per-package way of pinning the provider - no longer exists in 4.0.x. |
| | | --> |
| | | <dependency> |
| | | <groupId>jakarta.xml.bind</groupId> |
| | | <artifactId>jakarta.xml.bind-api</artifactId> |
| | | <version>3.0.1</version> |
| | | <version>4.0.2</version> |
| | | </dependency> |
| | | |
| | | <dependency> |
| | |
| | | independently, so a DNS name whose records change between the two lookups |
| | | (DNS rebinding) can still reach an internal address; only enable |
| | | dereferencing for trusted clients. |
| | | Enabling this also sets the JVM-global "mapAnyUriToUri" system property, |
| | | which the JAXB reference implementation reads once, when it initialises: |
| | | every other application in the same JVM that initialises JAXB afterwards |
| | | then binds xsd:anyURI to java.net.URI instead of to java.lang.String |
| | | (GHSA-jr68-5c58-j463). In this gateway that binding also makes an anyURI |
| | | value which is not a valid java.net.URI - one holding a space, a pipe or a |
| | | brace - be rejected with an unresolvableURI error instead of being stored |
| | | verbatim. |
| | | <context-param> |
| | | <description>Enable server-side dereferencing of anyURI values (default false)</description> |
| | | <param-name>ldap.dsml.dereference.anyuri</param-name> |
| | |
| | | } |
| | | |
| | | /** |
| | | * Returns a ByteString from a DsmlValue Object which the request requires to |
| | | * carry a value. |
| | | * <p> |
| | | * A value the binding could not produce arrives here as {@code null} rather |
| | | * than as a parse failure: when {@code ldap.dsml.dereference.anyuri} is |
| | | * enabled, {@code xsd:anyURI} is bound to {@link URI}, and the JAXB runtime |
| | | * turns a lexical form {@link URI} rejects - one holding a space, a pipe or a |
| | | * brace, all of them valid {@code xsd:anyURI} - into a {@code null} element. |
| | | * Passing that on would silently drop the value from an add or modify |
| | | * request, or build a search filter with no assertion value, so it is |
| | | * reported to the client instead (GHSA-jr68-5c58-j463). |
| | | * |
| | | * @param obj |
| | | * the DsmlValue object. |
| | | * @return a new ByteString object with the value, never {@code null}. |
| | | * @throws IOException if the value is missing or could not be converted, or |
| | | * if any problem occurred retrieving an anyURI value. |
| | | */ |
| | | static ByteString convertRequiredValue(Object obj) throws IOException |
| | | { |
| | | ByteString value = convertValue(obj); |
| | | if (value == null) |
| | | { |
| | | throw new IOException("Unable to convert a DSML value into an attribute " |
| | | + "value: the value is missing, or it is an anyURI which is not a " |
| | | + "valid URI and was dropped by the XML binding"); |
| | | } |
| | | return value; |
| | | } |
| | | |
| | | /** |
| | | * Converts an xsd:anyURI value into a ByteString. |
| | | * <p> |
| | | * Unless dereferencing has been explicitly enabled, the URI is treated as an |
| | |
| | | * |
| | | * Copyright 2006-2008 Sun Microsystems, Inc. |
| | | * Portions Copyright 2012-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.dsml.protocol; |
| | | |
| | |
| | | List vals = attr.getValue(); |
| | | for(Object val : vals) |
| | | { |
| | | values.add(ByteStringUtility.convertValue(val)); |
| | | values.add(ByteStringUtility.convertRequiredValue(val)); |
| | | } |
| | | LDAPAttribute ldapAttribute = new LDAPAttribute(attr.getName(), values); |
| | | attributes.add(ldapAttribute); |
| | |
| | | * |
| | | * Copyright 2006-2008 Sun Microsystems, Inc. |
| | | * Portions Copyright 2012-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.dsml.protocol; |
| | | |
| | |
| | | AttributeValueAssertion attrValAssertion = compareRequest.getAssertion(); |
| | | String attrName = attrValAssertion.getName(); |
| | | Object assertion = attrValAssertion.getValue(); |
| | | ByteString attrValue = ByteStringUtility.convertValue(assertion); |
| | | ByteString attrValue = ByteStringUtility.convertRequiredValue(assertion); |
| | | ByteString dnStr = ByteString.valueOfUtf8(compareRequest.getDn()); |
| | | |
| | | // Create and send the LDAP compare request to the server. |
| | |
| | | * |
| | | * Copyright 2006-2008 Sun Microsystems, Inc. |
| | | * Portions Copyright 2012-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.dsml.protocol; |
| | | |
| | |
| | | |
| | | for (Object val : attr.getValue()) |
| | | { |
| | | values.add(ByteStringUtility.convertValue(val)); |
| | | values.add(ByteStringUtility.convertRequiredValue(val)); |
| | | } |
| | | LDAPAttribute ldapAttr = new LDAPAttribute(attrType, values); |
| | | |
| | |
| | | * |
| | | * Copyright 2006-2009 Sun Microsystems, Inc. |
| | | * Portions Copyright 2012-2016 ForgeRock AS. |
| | | * Portions Copyright 2026 3A Systems, LLC. |
| | | */ |
| | | package org.opends.dsml.protocol; |
| | | |
| | |
| | | throws IOException |
| | | { |
| | | return LDAPFilter.createApproximateFilter(ava.getName(), |
| | | ByteStringUtility.convertValue(ava.getValue())); |
| | | ByteStringUtility.convertRequiredValue(ava.getValue())); |
| | | } |
| | | |
| | | |
| | |
| | | throws IOException |
| | | { |
| | | return LDAPFilter.createEqualityFilter(ava.getName(), |
| | | ByteStringUtility.convertValue(ava.getValue())); |
| | | ByteStringUtility.convertRequiredValue(ava.getValue())); |
| | | } |
| | | |
| | | |
| | |
| | | throws IOException |
| | | { |
| | | return LDAPFilter.createExtensibleFilter(mra.getMatchingRule(), mra |
| | | .getName(), ByteStringUtility.convertValue(mra.getValue()), |
| | | .getName(), ByteStringUtility.convertRequiredValue(mra.getValue()), |
| | | mra.isDnAttributes()); |
| | | } |
| | | |
| | |
| | | throws IOException |
| | | { |
| | | return LDAPFilter.createGreaterOrEqualFilter(ava.getName(), |
| | | ByteStringUtility.convertValue(ava.getValue())); |
| | | ByteStringUtility.convertRequiredValue(ava.getValue())); |
| | | } |
| | | |
| | | |
| | |
| | | throws IOException |
| | | { |
| | | return LDAPFilter.createLessOrEqualFilter(ava.getName(), |
| | | ByteStringUtility.convertValue(ava.getValue())); |
| | | ByteStringUtility.convertRequiredValue(ava.getValue())); |
| | | } |
| | | |
| | | |
| | |
| | | |
| | | for (Object o : anyo) |
| | | { |
| | | subAnyElements.add(ByteStringUtility.convertValue(o)); |
| | | subAnyElements.add(ByteStringUtility.convertRequiredValue(o)); |
| | | } |
| | | if(sf.getInitial() == null && subAnyElements.isEmpty() |
| | | && sf.getFinal()==null) |
| | |
| | | } |
| | | return LDAPFilter.createSubstringFilter(sf.getName(), |
| | | sf.getInitial() == null ? null : ByteStringUtility |
| | | .convertValue(sf.getInitial()), |
| | | .convertRequiredValue(sf.getInitial()), |
| | | subAnyElements, |
| | | sf.getFinal() == null ? null : ByteStringUtility |
| | | .convertValue(sf.getFinal())); |
| | | .convertRequiredValue(sf.getFinal())); |
| | | } |
| | | |
| | | |
| | |
| | | private static final String TRUSTALLCERTS = "ldap.trustall"; |
| | | private static final String USEHTTPAUTHZID = "ldap.authzidtypeisid"; |
| | | private static final String EXOPSTRINGPREFIX = "ldap.exop.string."; |
| | | /** |
| | | * JAXB RI system property mapping xsd:anyURI to java.net.URI instead of |
| | | * java.lang.String. See the caveats in {@link #init(ServletConfig)}. |
| | | */ |
| | | private static final String MAP_ANYURI_TO_URI = "mapAnyUriToUri"; |
| | | private static final String DEREF_ANYURI = "ldap.dsml.dereference.anyuri"; |
| | | private static final String DEREF_ANYURI_SCHEMES = "ldap.dsml.dereference.anyuri.schemes"; |
| | | private static final String DEREF_ANYURI_MAXSIZE = "ldap.dsml.dereference.anyuri.maxsize"; |
| | |
| | | } |
| | | } |
| | | |
| | | // Materialise anyURI values as java.net.URI so that, when dereferencing |
| | | // is explicitly enabled, they can be fetched. Dereferencing itself is |
| | | // gated and hardened in ByteStringUtility (disabled by default). |
| | | System.setProperty("mapAnyUriToUri", "true"); |
| | | |
| | | // Server-side dereferencing of anyURI values is disabled by default |
| | | // (SSRF / local-file / unbounded-read hardening, GHSA-68r5-9hpg-7qw9). |
| | | // Enable it explicitly, and optionally tune the scheme allowlist and the |
| | |
| | | ByteStringUtility.setDereferenceUri(derefAnyUri); |
| | | if (derefAnyUri) |
| | | { |
| | | // Materialise anyURI values as java.net.URI, otherwise they are bound |
| | | // to String and are stored verbatim rather than dereferenced. This is |
| | | // only done when dereferencing is wanted, because the property comes |
| | | // with four caveats (GHSA-jr68-5c58-j463): |
| | | // - it is JVM-global rather than per-JAXBContext, so it also changes |
| | | // the anyURI binding of every other application initialising JAXB in |
| | | // this JVM afterwards; |
| | | // - the JAXB RI tests it for presence, not for its value, so setting |
| | | // it to "false" would enable the mapping just as well; |
| | | // - it is read once, by the static initialiser of the JAXB runtime, so |
| | | // it has no effect if JAXB has already been initialised, and only the |
| | | // JAXB RI honours it at all - EclipseLink MOXy ignores it, which is |
| | | // why the gateway must ship exactly one Jakarta XML Binding provider |
| | | // (pinned by JaxbProviderTestCase); |
| | | // - the URI binding narrows the accepted values: a lexical form which |
| | | // is a valid xsd:anyURI but not a valid java.net.URI is turned into a |
| | | // null by the runtime, and is then reported to the client as an |
| | | // unresolvableURI error rather than being silently dropped (see |
| | | // ByteStringUtility.convertRequiredValue). |
| | | System.setProperty(MAP_ANYURI_TO_URI, "true"); |
| | | |
| | | String schemes = stringValue(config, DEREF_ANYURI_SCHEMES); |
| | | if (schemes != null && !schemes.trim().isEmpty()) |
| | | { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Check if using the proxy authz control will work, by using it to read |
| | | * the Root DSE. |
| | | * Pre-flight probe telling whether the proxy authz control is usable, by |
| | | * sending it along with a Root DSE read. |
| | | * <p> |
| | | * This is a fail-fast diagnostic, not an access decision: the control it |
| | | * returns is attached to every operation of the batch request, and the |
| | | * server evaluates it independently on each of them. That evaluation |
| | | * requires the authenticated identity to hold the {@code proxied-auth} |
| | | * privilege, the authorization ID to resolve to a usable entry, and an ACI |
| | | * granting the authenticated identity the {@code proxy} right over that |
| | | * entry; ordinary per-entry ACI evaluation then applies to the entries the |
| | | * operation touches. The probe runs the very same checks - the Root DSE is |
| | | * served by a local backend - so it only turns an authorization failure that |
| | | * would happen on the first operation into an error reported up front. |
| | | * |
| | | * @param connection The authenticated LDAP connection used to check. |
| | | * @param authorizationID The authorization ID, in the format |
| | |
| | | * |
| | | * @return a JAXBElement that contains an ErrorResponse |
| | | */ |
| | | private JAXBElement<ErrorResponse> createXMLParsingErrorResponse( |
| | | // Package-private rather than private so that DSMLXmlParsingTestCase can |
| | | // exercise the fallback parser on the payloads SAAJ rejects. |
| | | JAXBElement<ErrorResponse> createXMLParsingErrorResponse( |
| | | InputStream is, |
| | | ObjectFactory objFactory, |
| | | BatchResponse batchResponse, |
| | |
| | | * @throws ParserConfigurationException if we cannot obtain a parser. |
| | | * @throws SAXException if we cannot obtain a parser. |
| | | */ |
| | | private XMLReader createSafeXMLReader() |
| | | // Package-private rather than private so that DSMLXmlParsingTestCase can |
| | | // assert that the hardening features really did take effect: safeSetFeature |
| | | // logs and continues when a feature is unsupported by the parser in use. |
| | | XMLReader createSafeXMLReader() |
| | | throws ParserConfigurationException, SAXException |
| | | { |
| | | final SAXParserFactory saxParserFactory = SAXParserFactory.newInstance(); |
| | |
| | | ByteStringUtility.setMaxUriContentLength(-1); |
| | | } |
| | | |
| | | @DataProvider |
| | | public Object[][] convertibleValues() |
| | | { |
| | | return new Object[][] { |
| | | { "a string value", ByteString.valueOfUtf8("a string value") }, |
| | | { new byte[] { 1, 2, 3 }, ByteString.wrap(new byte[] { 1, 2, 3 }) }, |
| | | { URI.create("http://203.0.113.10/secret"), |
| | | ByteString.valueOfUtf8("http://203.0.113.10/secret") }, |
| | | }; |
| | | } |
| | | |
| | | @Test(dataProvider = "convertibleValues") |
| | | public void testRequiredValueIsConverted(Object value, ByteString expected) throws Exception |
| | | { |
| | | assertEquals(ByteStringUtility.convertRequiredValue(value), expected); |
| | | } |
| | | |
| | | /** |
| | | * The XML binding turns an anyURI whose lexical form java.net.URI rejects |
| | | * into a null value. Storing that would silently drop the value from the |
| | | * request, so it is reported to the client instead. |
| | | */ |
| | | @Test(expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*value is missing.*") |
| | | public void testMissingRequiredValueIsRejected() throws Exception |
| | | { |
| | | ByteStringUtility.convertRequiredValue(null); |
| | | } |
| | | |
| | | @Test(expectedExceptions = IOException.class, |
| | | expectedExceptionsMessageRegExp = ".*value is missing.*") |
| | | public void testUnconvertibleRequiredValueIsRejected() throws Exception |
| | | { |
| | | ByteStringUtility.convertRequiredValue(Integer.valueOf(42)); |
| | | } |
| | | |
| | | @Test |
| | | public void testSchemeCheckPrecedesHostCheck() throws Exception |
| | | { |
| New file |
| | |
| | | /* |
| | | * 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). |
| | | * <p> |
| | | * {@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}). |
| | | * <p> |
| | | * 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"), |
| | | ("<!ENTITY xxe SYSTEM \"" + canaryUri + "\">").getBytes(StandardCharsets.UTF_8)); |
| | | externalDtdUri = dtd.toUri().toASCIIString(); |
| | | } |
| | | |
| | | @AfterClass |
| | | public void deletePayloadFiles() throws IOException |
| | | { |
| | | if (tempDir != null) |
| | | { |
| | | try (Stream<Path> 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", |
| | | "<?xml version=\"1.0\"?>" |
| | | + "<!DOCTYPE batchRequest [<!ENTITY xxe SYSTEM \"" + canaryUri + "\">]>" |
| | | + batchRequest("&xxe;") }, |
| | | { "external DTD subset", |
| | | "<?xml version=\"1.0\"?>" |
| | | + "<!DOCTYPE batchRequest SYSTEM \"" + externalDtdUri + "\">" |
| | | + 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<ErrorResponse> 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<ErrorResponse> 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 "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\" requestID=\"42\">" |
| | | + "<addRequest dn=\"cn=probe,dc=example,dc=com\">" |
| | | + "<attr name=\"description\"><value>" + value + "</value></attr>" |
| | | + "</addRequest></batchRequest>"; |
| | | } |
| | | |
| | | /** 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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.net.URL; |
| | | import java.util.ArrayList; |
| | | import java.util.Collections; |
| | | import java.util.List; |
| | | |
| | | import org.forgerock.testng.ForgeRockTestCase; |
| | | import org.testng.annotations.Test; |
| | | |
| | | import jakarta.xml.bind.JAXBContext; |
| | | |
| | | import static org.testng.Assert.assertEquals; |
| | | import static org.testng.Assert.assertTrue; |
| | | |
| | | /** |
| | | * Pins the gateway to a single, known Jakarta XML Binding provider. |
| | | * <p> |
| | | * {@code JAXBContext.newInstance(String, ClassLoader)} resolves its provider |
| | | * through the {@code ServiceLoader}, so with two providers on the classpath the |
| | | * one serving the DSMLv2 bindings is whichever the container's classloader |
| | | * happens to reach first (GHSA-jr68-5c58-j463). That decides observable |
| | | * behaviour: {@code mapAnyUriToUri}, which the gateway sets when anyURI |
| | | * dereferencing is enabled, is honoured by the JAXB reference implementation |
| | | * and ignored by EclipseLink MOXy. |
| | | * <p> |
| | | * The web application therefore has to ship exactly one provider. There is no |
| | | * way to state that in the pom, and pinning it per package through a |
| | | * {@code jaxb.properties} file is not an option either - that lookup was |
| | | * removed from the API in 4.0 and is silently ignored there - so it is asserted |
| | | * here: a dependency change that reintroduces a second provider fails this |
| | | * test rather than changing behaviour in one container only. |
| | | */ |
| | | @SuppressWarnings("javadoc") |
| | | @Test(groups = { "precommit", "dsml" }) |
| | | public class JaxbProviderTestCase extends ForgeRockTestCase |
| | | { |
| | | private static final String PROVIDER_SERVICE = "META-INF/services/jakarta.xml.bind.JAXBContextFactory"; |
| | | private static final String JAXB_RI_PACKAGE = "org.glassfish.jaxb.runtime."; |
| | | |
| | | @Test |
| | | public void testExactlyOneProviderIsOnTheClasspath() throws Exception |
| | | { |
| | | List<URL> providers = |
| | | Collections.list(getClass().getClassLoader().getResources(PROVIDER_SERVICE)); |
| | | |
| | | assertEquals(providers.size(), 1, "expected a single Jakarta XML Binding provider, found " |
| | | + describe(providers)); |
| | | } |
| | | |
| | | @Test |
| | | public void testDsmlBindingsAreServedByTheJaxbReferenceImplementation() throws Exception |
| | | { |
| | | JAXBContext context = |
| | | JAXBContext.newInstance("org.opends.dsml.protocol", getClass().getClassLoader()); |
| | | |
| | | assertTrue(context.getClass().getName().startsWith(JAXB_RI_PACKAGE), |
| | | "the DSMLv2 bindings are served by " + context.getClass().getName() |
| | | + ", which does not honour the mapAnyUriToUri property"); |
| | | } |
| | | |
| | | private String describe(List<URL> providers) |
| | | { |
| | | List<String> descriptions = new ArrayList<>(providers.size()); |
| | | for (URL provider : providers) |
| | | { |
| | | descriptions.add(provider.toString()); |
| | | } |
| | | return descriptions.toString(); |
| | | } |
| | | } |
| New file |
| | |
| | | /* |
| | | * 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. |
| | | * <p> |
| | | * {@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). |
| | | * <p> |
| | | * 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"), |
| | | ("<!ENTITY xxe SYSTEM \"" + canaryUri + "\">").getBytes(StandardCharsets.UTF_8)); |
| | | externalDtdUri = dtd.toUri().toASCIIString(); |
| | | } |
| | | |
| | | @AfterClass |
| | | public void deletePayloadFiles() throws IOException |
| | | { |
| | | if (tempDir != null) |
| | | { |
| | | try (Stream<Path> 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, |
| | | "<?xml version=\"1.0\"?>" |
| | | + "<!DOCTYPE soapenv:Envelope [<!ENTITY xxe SYSTEM \"" + canaryUri + "\">]>" |
| | | + soap11Envelope("&xxe;") }, |
| | | { "SOAP 1.1, external DTD subset", SOAPConstants.SOAP_1_1_PROTOCOL, SOAP_11_CONTENT_TYPE, |
| | | "<?xml version=\"1.0\"?>" |
| | | + "<!DOCTYPE soapenv:Envelope SYSTEM \"" + externalDtdUri + "\">" |
| | | + soap11Envelope("&xxe;") }, |
| | | { "SOAP 1.2, external general entity", SOAPConstants.SOAP_1_2_PROTOCOL, SOAP_12_CONTENT_TYPE, |
| | | "<?xml version=\"1.0\"?>" |
| | | + "<!DOCTYPE env:Envelope [<!ENTITY xxe SYSTEM \"" + canaryUri + "\">]>" |
| | | + soap12Envelope("&xxe;") }, |
| | | { "SOAP 1.2, external DTD subset", SOAPConstants.SOAP_1_2_PROTOCOL, SOAP_12_CONTENT_TYPE, |
| | | "<?xml version=\"1.0\"?>" |
| | | + "<!DOCTYPE env:Envelope SYSTEM \"" + externalDtdUri + "\">" |
| | | + 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, |
| | | "<?xml version=\"1.0\"?>" + 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 "<soapenv:Envelope xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\">" |
| | | + "<soapenv:Body>" + batchRequest(dn) + "</soapenv:Body></soapenv:Envelope>"; |
| | | } |
| | | |
| | | private static String soap12Envelope(String dn) |
| | | { |
| | | return "<env:Envelope xmlns:env=\"http://www.w3.org/2003/05/soap-envelope\">" |
| | | + "<env:Body>" + batchRequest(dn) + "</env:Body></env:Envelope>"; |
| | | } |
| | | |
| | | /** |
| | | * 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 "<batchRequest xmlns=\"urn:oasis:names:tc:DSML:2:0:core\">" |
| | | + "<addRequest dn=\"cn=probe,dc=example,dc=com\">" |
| | | + "<attr name=\"description\"><value>" + MARKER + value + "</value></attr>" |
| | | + "</addRequest></batchRequest>"; |
| | | } |
| | | } |