From 131e8576dcf3613f944c3e02527959bbf52370c3 Mon Sep 17 00:00:00 2001
From: Valera V Harseko <vharseko@3a-systems.ru>
Date: Fri, 17 Jul 2026 11:17:33 +0000
Subject: [PATCH] GHSA-68r5-9hpg-7qw9 Unauthenticated SSRF, local file read and unbounded-read DoS in the DSMLv2 gateway
---
opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java | 251 ++++++++++++++++++++++++++++++++++++++++++++++++--
1 files changed, 241 insertions(+), 10 deletions(-)
diff --git a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java
index c94cb56..1febf74 100644
--- a/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java
+++ b/opendj-dsml-servlet/src/main/java/org/opends/dsml/protocol/ByteStringUtility.java
@@ -12,12 +12,22 @@
* information: "Portions Copyright [year] [name of copyright owner]".
*
* Copyright 2012-2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
*/
package org.opends.dsml.protocol;
import java.io.IOException;
import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.net.InetAddress;
import java.net.URI;
+import java.net.URLConnection;
+import java.net.UnknownHostException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
import org.forgerock.opendj.ldap.ByteString;
import org.forgerock.opendj.ldap.ByteStringBuilder;
@@ -29,6 +39,79 @@
*/
class ByteStringUtility
{
+ /** Number of bytes read from a dereferenced URI per iteration. */
+ private static final int READ_CHUNK = 2048;
+
+ /** Connect timeout (ms) applied when dereferencing an anyURI value. */
+ private static final int CONNECT_TIMEOUT_MS = 10000;
+
+ /** Read timeout (ms) applied when dereferencing an anyURI value. */
+ private static final int READ_TIMEOUT_MS = 30000;
+
+ /**
+ * Whether xsd:anyURI values are dereferenced (fetched) server-side.
+ * <p>
+ * Disabled by default: fetching an attacker-supplied URI on the server is a
+ * server-side request forgery, local-file disclosure and unbounded-read DoS
+ * primitive (GHSA-68r5-9hpg-7qw9). When disabled, an anyURI value is stored
+ * verbatim (its string form) instead of being dereferenced.
+ */
+ private static volatile boolean dereferenceUri;
+
+ /** URI schemes that may be dereferenced when {@link #dereferenceUri} is enabled. */
+ private static volatile Set<String> allowedUriSchemes =
+ Collections.unmodifiableSet(new HashSet<>(Arrays.asList("http", "https")));
+
+ /** Maximum number of bytes read from a dereferenced URI (10 MiB by default). */
+ private static volatile long maxUriContentLength = 10L * 1024 * 1024;
+
+ /**
+ * Enables or disables server-side dereferencing of xsd:anyURI values.
+ *
+ * @param enabled {@code true} to fetch anyURI values (subject to the scheme
+ * allowlist, address filter and size cap), {@code false} to
+ * store them verbatim.
+ */
+ static void setDereferenceUri(boolean enabled)
+ {
+ dereferenceUri = enabled;
+ }
+
+ /**
+ * Sets the schemes that may be dereferenced from a comma-separated list.
+ *
+ * @param schemes comma-separated scheme names (e.g. {@code "http,https"}).
+ */
+ static void setAllowedUriSchemes(String schemes)
+ {
+ Set<String> parsed = new HashSet<>();
+ for (String scheme : schemes.split(","))
+ {
+ String trimmed = scheme.trim();
+ if (!trimmed.isEmpty())
+ {
+ parsed.add(trimmed.toLowerCase(Locale.ROOT));
+ }
+ }
+ allowedUriSchemes = Collections.unmodifiableSet(parsed);
+ }
+
+ /**
+ * Sets the maximum number of bytes read from a dereferenced URI.
+ *
+ * @param maxLength the maximum size, in bytes; must be positive.
+ * @throws IllegalArgumentException if the size is zero or negative.
+ */
+ static void setMaxUriContentLength(long maxLength)
+ {
+ if (maxLength <= 0)
+ {
+ throw new IllegalArgumentException(
+ "The maximum anyURI content length must be positive, but was: " + maxLength);
+ }
+ maxUriContentLength = maxLength;
+ }
+
/**
* Returns a ByteString from a DsmlValue Object.
*
@@ -54,16 +137,7 @@
}
else if (obj instanceof URI)
{
- // read raw content and return as a byte[].
- try (InputStream is = ((URI) obj).toURL().openStream())
- {
- ByteStringBuilder bsb = new ByteStringBuilder();
- while (bsb.appendBytes(is, 2048) != -1)
- {
- // do nothing
- }
- return bsb.toByteString();
- }
+ return convertUri((URI) obj);
}
else if (obj instanceof Element)
{
@@ -74,6 +148,163 @@
}
/**
+ * Converts an xsd:anyURI value into a ByteString.
+ * <p>
+ * Unless dereferencing has been explicitly enabled, the URI is treated as an
+ * opaque string and is not fetched. When enabled, the scheme must be on the
+ * allowlist, the target must not resolve to a loopback/link-local/private
+ * address (SSRF hardening), HTTP redirects are refused (following one would
+ * connect to a host that never went through the address filter) and the
+ * amount of data read is capped.
+ * <p>
+ * Known limitation: the address filter and the subsequent connection resolve
+ * the host name independently, so DNS records changing between the two
+ * lookups (DNS rebinding) can still direct the fetch at an internal address.
+ * The JVM's positive DNS cache ({@code networkaddress.cache.ttl}, 30 seconds
+ * by default) narrows this window but does not close it; only enable
+ * dereferencing for trusted clients.
+ *
+ * @param uri the anyURI value.
+ * @return the resulting ByteString.
+ * @throws IOException if the URI is rejected or an I/O error occurs.
+ */
+ static ByteString convertUri(URI uri) throws IOException
+ {
+ if (!dereferenceUri)
+ {
+ // Secure default: do not fetch remote or local content.
+ return ByteString.valueOfUtf8(uri.toString());
+ }
+
+ String scheme = uri.getScheme();
+ if (scheme == null || !allowedUriSchemes.contains(scheme.toLowerCase(Locale.ROOT)))
+ {
+ throw new IOException(
+ "Refusing to dereference anyURI value with disallowed scheme: " + uri);
+ }
+ assertHostAllowed(uri);
+
+ URLConnection connection = uri.toURL().openConnection();
+ connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
+ connection.setReadTimeout(READ_TIMEOUT_MS);
+ if (connection instanceof HttpURLConnection)
+ {
+ HttpURLConnection httpConnection = (HttpURLConnection) connection;
+ httpConnection.setInstanceFollowRedirects(false);
+ assertNotRedirect(httpConnection.getResponseCode(), uri);
+ }
+ try (InputStream is = connection.getInputStream())
+ {
+ return readCapped(is, uri);
+ }
+ }
+
+ /**
+ * Rejects HTTP 3xx responses: the redirect target has not been validated by
+ * {@link #assertHostAllowed}, so following it would let an allowlisted public
+ * host bounce the gateway onto an internal or metadata address.
+ *
+ * @param responseCode the HTTP response code.
+ * @param uri the anyURI value being dereferenced.
+ * @throws IOException if the response is a redirect.
+ */
+ static void assertNotRedirect(int responseCode, URI uri) throws IOException
+ {
+ if (responseCode >= 300 && responseCode < 400)
+ {
+ throw new IOException("Refusing to follow redirect (HTTP " + responseCode
+ + ") while dereferencing anyURI value: " + uri);
+ }
+ }
+
+ /**
+ * Reads a stream into a ByteString, enforcing the configured size cap.
+ *
+ * @param is the stream to read.
+ * @param uri the anyURI value being dereferenced, for diagnostics.
+ * @return the stream content.
+ * @throws IOException on I/O error, or if the content exceeds the cap.
+ */
+ static ByteString readCapped(InputStream is, URI uri) throws IOException
+ {
+ ByteStringBuilder bsb = new ByteStringBuilder();
+ while (bsb.appendBytes(is, READ_CHUNK) != -1)
+ {
+ if (bsb.length() > maxUriContentLength)
+ {
+ throw new IOException("anyURI content exceeds the maximum allowed size of "
+ + maxUriContentLength + " bytes: " + uri);
+ }
+ }
+ return bsb.toByteString();
+ }
+
+ /**
+ * Rejects a URI whose host resolves (in whole or in part) to a non-routable
+ * or otherwise internal address, to prevent server-side request forgery
+ * against internal services and the cloud metadata endpoint.
+ *
+ * @param uri the anyURI value being dereferenced.
+ * @throws IOException if the host is missing, unresolvable or internal.
+ */
+ static void assertHostAllowed(URI uri) throws IOException
+ {
+ String host = uri.getHost();
+ if (host == null)
+ {
+ throw new IOException("Refusing to dereference anyURI value with no host: " + uri);
+ }
+ InetAddress[] addresses;
+ try
+ {
+ addresses = InetAddress.getAllByName(host);
+ }
+ catch (UnknownHostException e)
+ {
+ throw new IOException("Unable to resolve anyURI host: " + host, e);
+ }
+ for (InetAddress address : addresses)
+ {
+ if (isInternalAddress(address))
+ {
+ throw new IOException("Refusing to dereference anyURI value targeting a "
+ + "non-routable/internal address: " + host + " -> " + address.getHostAddress());
+ }
+ }
+ }
+
+ /**
+ * Returns {@code true} if the address is one the gateway must not connect to
+ * on behalf of a request: loopback, wildcard, link-local (covers the
+ * 169.254.169.254 metadata endpoint), site-local/private, multicast, an
+ * IANA-reserved IPv4 range (including 100.64.0.0/10, home of the Alibaba
+ * Cloud metadata endpoint 100.100.100.200), or an IPv6 unique-local address
+ * (fc00::/7).
+ */
+ static boolean isInternalAddress(InetAddress address)
+ {
+ if (address.isLoopbackAddress() || address.isAnyLocalAddress()
+ || address.isLinkLocalAddress() || address.isSiteLocalAddress()
+ || address.isMulticastAddress())
+ {
+ return true;
+ }
+ byte[] bytes = address.getAddress();
+ if (bytes.length == 4)
+ {
+ int b0 = bytes[0] & 0xff;
+ int b1 = bytes[1] & 0xff;
+ return b0 == 0 // 0.0.0.0/8 "this network"
+ || (b0 == 100 && (b1 & 0xc0) == 0x40) // 100.64.0.0/10 CGNAT
+ || (b0 == 192 && b1 == 0 && (bytes[2] & 0xff) == 0) // 192.0.0.0/24 IETF assignments
+ || (b0 == 198 && (b1 & 0xfe) == 18) // 198.18.0.0/15 benchmarking
+ || b0 >= 240; // 240.0.0.0/4 reserved + broadcast
+ }
+ // IPv6 unique-local addresses (fc00::/7) are not covered by isSiteLocalAddress().
+ return bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc;
+ }
+
+ /**
* Returns a DsmlValue (Object) from an LDAP ByteString. The conversion is
* simplistic - try and convert it to UTF-8 and if that fails return a byte[].
*
--
Gitblit v1.10.0