/* * 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 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.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Set; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.opendj.ldap.ByteStringBuilder; import org.w3c.dom.Element; /** * A utility class to assist in converting DsmlValues (in Objects) into * the required ByteStrings, and back again. */ 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. *

* 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 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; /** * NAT64 Network-Specific Prefixes (RFC 6052 section 2.2) configured by the * deployer. An NSP is chosen by an operator out of their own address space * and is indistinguishable from an ordinary global address, so it cannot be * detected here; deployments using NAT64 with an NSP must declare it so that * the IPv4 address it embeds goes through the same filter as the built-in * transition prefixes. Empty by default. */ private static volatile List nat64Prefixes = Collections.emptyList(); /** * 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 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; } /** * Sets the NAT64 Network-Specific Prefixes from a comma-separated list of * {@code prefix/length} entries (e.g. {@code "2001:db8:122:344::/96"}). The * length must be one of the RFC 6052 section 2.2 prefix lengths: 32, 40, 48, * 56, 64 or 96. An empty string clears the list. * * @param prefixes comma-separated NAT64 prefixes in {@code prefix/length} form. * @throws IllegalArgumentException if an entry is not a valid IPv6 literal * with an RFC 6052 prefix length. */ static void setNat64Prefixes(String prefixes) { List parsed = new ArrayList<>(); for (String entry : prefixes.split(",")) { String trimmed = entry.trim(); if (!trimmed.isEmpty()) { parsed.add(Nat64Prefix.valueOf(trimmed)); } } nat64Prefixes = Collections.unmodifiableList(parsed); } /** * Returns a ByteString from a DsmlValue Object. * * @param obj * the DsmlValue object. * @return a new ByteString object with the value, or null if val was null, * or if it could not be converted. * @throws IOException if any problems occurred retrieving an anyURI value. */ public static ByteString convertValue(Object obj) throws IOException { if (obj == null) { return null; } else if (obj instanceof String) { return ByteString.valueOfUtf8((String) obj); } else if (obj instanceof byte[]) { return ByteString.wrap((byte[]) obj); } else if (obj instanceof URI) { return convertUri((URI) obj); } else if (obj instanceof Element) { Element element = (Element) obj; return ByteString.valueOfUtf8(element.getTextContent()); } return null; } /** * Converts an xsd:anyURI value into a ByteString. *

* 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. *

* 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), an IPv6 unique-local address * (fc00::/7), an address in the NAT64 local-use prefix 64:ff9b:1::/48 * (RFC 8215), or an IPv6 transition address (NAT64 well-known prefix * 64:ff9b::/96, any NAT64 prefix declared via * {@link #setNat64Prefixes(String)}, 6to4 2002::/16, Teredo 2001:0000::/32, * IPv4-mapped/-compatible) that embeds an internal IPv4 address * (GHSA-97x5-8pm7-xfp7). */ 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) { return isInternalIPv4(bytes); } if (bytes.length == 16) { // IPv6 unique-local addresses (fc00::/7) are not covered by isSiteLocalAddress(). if ((bytes[0] & 0xfe) == 0xfc) { return true; } // The NAT64 local-use prefix 64:ff9b:1::/48 (RFC 8215) is translation-only // space reserved for use within a site, and the operator chooses the // sub-prefix inside it, so the offset of the embedded IPv4 address is not // knowable here (a /64 sub-prefix puts it in bytes 9..12 rather than // 12..15). RFC 6052 section 3.1 forbids representing non-global IPv4 // addresses with the well-known prefix and RFC 8215 exists to provide a // prefix that may be used for exactly that, so this /48 is more likely to // carry an internal target than the well-known prefix is. Nothing in it is // a legitimate fetch target, so block the whole /48 rather than only the // sub-prefixes whose layout can be guessed. if (bytes[0] == 0x00 && bytes[1] == 0x64 && (bytes[2] & 0xff) == 0xff && (bytes[3] & 0xff) == 0x9b && bytes[4] == 0x00 && bytes[5] == 0x01) { return true; } // A transition address may embed an internal IPv4 address that would // otherwise bypass the IPv4 filter (GHSA-97x5-8pm7-xfp7). byte[] embedded = embeddedIPv4(bytes); return embedded != null && isInternalIPv4(embedded); } return false; } /** * Returns {@code true} if the given four-byte IPv4 address is loopback, * private, link-local, multicast or otherwise reserved/non-routable. Unlike * the {@link InetAddress} predicates, this operates on raw bytes so it can * also classify an IPv4 address extracted from an IPv6 transition address. */ private static boolean isInternalIPv4(byte[] v4) { int b0 = v4[0] & 0xff; int b1 = v4[1] & 0xff; int b2 = v4[2] & 0xff; return b0 == 0 // 0.0.0.0/8 "this network" || b0 == 10 // 10.0.0.0/8 private || b0 == 127 // 127.0.0.0/8 loopback || (b0 == 100 && (b1 & 0xc0) == 0x40) // 100.64.0.0/10 CGNAT || (b0 == 169 && b1 == 254) // 169.254.0.0/16 link-local || (b0 == 172 && (b1 & 0xf0) == 0x10) // 172.16.0.0/12 private || (b0 == 192 && b1 == 168) // 192.168.0.0/16 private || (b0 == 192 && b1 == 0 && b2 == 0) // 192.0.0.0/24 IETF assignments || (b0 == 192 && b1 == 88 && b2 == 99) // 192.88.99.0/24 6to4 relay anycast (deprecated, RFC 7526) || (b0 == 198 && (b1 & 0xfe) == 18) // 198.18.0.0/15 benchmarking || (b0 & 0xf0) == 0xe0 // 224.0.0.0/4 multicast || b0 >= 240; // 240.0.0.0/4 reserved + broadcast } /** * Extracts the IPv4 address embedded in an IPv6 transition address, or * {@code null} if none is embedded. Covers 6to4 (2002::/16), Teredo * (2001:0000::/32, client address obfuscated), the NAT64 well-known prefix * (64:ff9b::/96), any NAT64 Network-Specific Prefix declared via * {@link #setNat64Prefixes(String)}, and IPv4-mapped/-compatible addresses * (::ffff:0:0/96, ::/96). The NAT64 local-use prefix 64:ff9b:1::/48 (RFC 8215) * is not decoded here: {@link #isInternalAddress(InetAddress)} rejects that * whole /48, whatever sub-prefix layout the operator picked inside it. */ private static byte[] embeddedIPv4(byte[] b) { int b0 = b[0] & 0xff; int b1 = b[1] & 0xff; // 6to4: 2002:V4ADDR::/16 -- IPv4 in bytes 2..5. if (b0 == 0x20 && b1 == 0x02) { return new byte[] { b[2], b[3], b[4], b[5] }; } // Teredo: 2001:0000::/32 -- client IPv4 in bytes 12..15, obfuscated (one's complement). if (b0 == 0x20 && b1 == 0x01 && (b[2] & 0xff) == 0x00 && (b[3] & 0xff) == 0x00) { return new byte[] { (byte) ~b[12], (byte) ~b[13], (byte) ~b[14], (byte) ~b[15] }; } // NAT64 well-known prefix: 64:ff9b::/96 -- IPv4 in bytes 12..15. RFC 6052 // defines the well-known prefix with a /96 length only, so this is complete; // the local-use prefix 64:ff9b:1::/48 needs no decoding because // isInternalAddress() rejects that whole /48 outright. if (b0 == 0x00 && b1 == 0x64 && (b[2] & 0xff) == 0xff && (b[3] & 0xff) == 0x9b && isZero(b, 4, 12)) { return new byte[] { b[12], b[13], b[14], b[15] }; } // IPv4-mapped (::ffff:0:0/96) and IPv4-compatible (::/96) -- IPv4 in bytes 12..15. if (isZero(b, 0, 10) && (((b[10] & 0xff) == 0xff && (b[11] & 0xff) == 0xff) // ::ffff:0:0/96 mapped || ((b[10] & 0xff) == 0x00 && (b[11] & 0xff) == 0x00))) // ::/96 compatible { return new byte[] { b[12], b[13], b[14], b[15] }; } // NAT64 Network-Specific Prefixes declared by the deployer (RFC 6052). for (Nat64Prefix prefix : nat64Prefixes) { if (prefix.matches(b)) { return prefix.extractIPv4(b); } } return null; } /** * A NAT64 translation prefix (RFC 6052 section 2.2): a 16-byte IPv6 prefix * and its length, one of 32, 40, 48, 56, 64 or 96 bits. The prefix length * determines where the translated IPv4 address sits in the IPv6 address * (bits 64..71, the "u" byte, never carry IPv4 bits). */ private static final class Nat64Prefix { private final byte[] prefix; private final int length; private Nat64Prefix(byte[] prefix, int length) { this.prefix = prefix; this.length = length; } /** * Parses a {@code prefix/length} entry, e.g. {@code "2001:db8:122:344::/96"}. * * @throws IllegalArgumentException if the entry is malformed, the address * part is not an IPv6 literal, or the length is not an RFC 6052 * prefix length. */ static Nat64Prefix valueOf(String entry) { int slash = entry.indexOf('/'); if (slash < 0) { throw new IllegalArgumentException( "NAT64 prefix must be in prefix/length form, but was: " + entry); } int length; try { length = Integer.parseInt(entry.substring(slash + 1).trim()); } catch (NumberFormatException e) { throw new IllegalArgumentException( "NAT64 prefix length is not a number: " + entry); } if (length != 32 && length != 40 && length != 48 && length != 56 && length != 64 && length != 96) { throw new IllegalArgumentException("NAT64 prefix length must be one of " + "32, 40, 48, 56, 64 or 96 (RFC 6052), but was: " + entry); } InetAddress address; try { // The bracketed form only accepts an IP literal, so this cannot // trigger a DNS lookup. address = InetAddress.getByName("[" + entry.substring(0, slash).trim() + "]"); } catch (UnknownHostException e) { throw new IllegalArgumentException( "NAT64 prefix is not an IPv6 literal: " + entry); } byte[] bytes = address.getAddress(); if (bytes.length != 16) { throw new IllegalArgumentException( "NAT64 prefix is not an IPv6 address: " + entry); } return new Nat64Prefix(bytes, length); } /** Returns {@code true} if the first {@code length} bits of {@code b} match this prefix. */ boolean matches(byte[] b) { for (int i = 0; i < length / 8; i++) { if (b[i] != prefix[i]) { return false; } } return true; } /** * Extracts the embedded IPv4 address as laid out by RFC 6052 section 2.2 * for this prefix length: the 32 IPv4 bits follow the prefix directly, * except that byte 8 (the "u" byte) is skipped. */ byte[] extractIPv4(byte[] b) { switch (length) { case 32: return new byte[] { b[4], b[5], b[6], b[7] }; case 40: return new byte[] { b[5], b[6], b[7], b[9] }; case 48: return new byte[] { b[6], b[7], b[9], b[10] }; case 56: return new byte[] { b[7], b[9], b[10], b[11] }; case 64: return new byte[] { b[9], b[10], b[11], b[12] }; default: return new byte[] { b[12], b[13], b[14], b[15] }; // 96 } } } /** Returns {@code true} if bytes {@code [from, to)} of {@code b} are all zero. */ private static boolean isZero(byte[] b, int from, int to) { for (int i = from; i < to; i++) { if (b[i] != 0) { return false; } } return true; } /** * 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[]. * * @param bs the ByteString returned from LDAP. * @return a String or a byte[]. */ public static Object convertByteString(ByteString bs) { try { return new String(bs.toCharArray()); } catch (Exception e) { return bs.toByteArray(); } } }