/* * 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.net.InetAddress; import java.net.URI; import org.forgerock.opendj.ldap.ByteString; import org.forgerock.testng.ForgeRockTestCase; import org.testng.annotations.AfterMethod; import org.testng.annotations.DataProvider; import org.testng.annotations.Test; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertTrue; /** * Tests the anyURI dereferencing hardening added for GHSA-68r5-9hpg-7qw9: * secure-by-default (no fetch), scheme allowlist, internal-address filter, * redirect refusal and content size cap. None of these tests performs network * or DNS I/O: hosts are IP literals and streams are in-memory. */ @SuppressWarnings("javadoc") @Test(groups = { "precommit", "dsml" }) public class ByteStringUtilityTestCase extends ForgeRockTestCase { @AfterMethod public void restoreDefaults() { ByteStringUtility.setDereferenceUri(false); ByteStringUtility.setAllowedUriSchemes("http,https"); ByteStringUtility.setMaxUriContentLength(10L * 1024 * 1024); ByteStringUtility.setNat64Prefixes(""); } @Test public void testUriIsStoredVerbatimByDefault() throws Exception { ByteString value = ByteStringUtility.convertValue(new URI("http://203.0.113.10/secret")); assertEquals(value, ByteString.valueOfUtf8("http://203.0.113.10/secret")); } @DataProvider public Object[][] disallowedSchemeUris() { return new Object[][] { { "file:///etc/passwd" }, { "ftp://203.0.113.10/file" }, { "gopher://203.0.113.10/1" }, { "jar:http://203.0.113.10/a.jar!/b" }, { "urn:isbn:0451450523" }, // opaque URI, no scheme match { "/relative/path" }, // no scheme at all }; } @Test(dataProvider = "disallowedSchemeUris", expectedExceptions = IOException.class, expectedExceptionsMessageRegExp = ".*disallowed scheme.*") public void testDisallowedSchemesAreRejected(String uri) throws Exception { ByteStringUtility.setDereferenceUri(true); ByteStringUtility.convertUri(new URI(uri)); } @Test(expectedExceptions = IOException.class, expectedExceptionsMessageRegExp = ".*disallowed scheme.*") public void testSchemeAllowlistIsConfigurable() throws Exception { ByteStringUtility.setDereferenceUri(true); ByteStringUtility.setAllowedUriSchemes(" HTTPS , "); // http is no longer on the allowlist, so it is rejected before any I/O. ByteStringUtility.convertUri(new URI("http://203.0.113.10/")); } @DataProvider public Object[][] internalUris() { return new Object[][] { { "http://127.0.0.1/" }, { "http://0.0.0.0/" }, { "http://10.1.2.3/" }, { "http://172.16.5.5/" }, { "http://192.168.1.1/" }, { "http://169.254.169.254/latest/meta-data/" }, { "http://100.100.100.200/latest/meta-data/" }, { "http://198.18.0.1/" }, { "http://240.0.0.1/" }, { "http://[::1]/" }, { "http://[fe80::1]/" }, { "http://[fc00::1]/" }, { "http://[fd12:3456::1]/" }, // IPv6 transition addresses embedding an internal IPv4 (GHSA-97x5-8pm7-xfp7). { "http://[64:ff9b::c0a8:0101]/" }, // NAT64 well-known -> 192.168.1.1 { "http://[64:ff9b::0a00:0001]/" }, // NAT64 well-known -> 10.0.0.1 { "http://[64:ff9b:1::7f00:1]/" }, // NAT64 local-use (RFC 8215) -> 127.0.0.1 { "http://[64:ff9b:1:0:c0:a801:100:0]/" }, // NAT64 local-use, /64 layout -> 192.168.1.1 { "http://[2002:0a00:0001::]/" }, // 6to4 -> 10.0.0.1 { "http://[2001:0000::3f57:fefe]/" }, // Teredo -> 192.168.1.1 (obfuscated) }; } @Test(dataProvider = "internalUris", expectedExceptions = IOException.class, expectedExceptionsMessageRegExp = ".*(internal|no host).*") public void testInternalAddressesAreRejected(String uri) throws Exception { ByteStringUtility.assertHostAllowed(new URI(uri)); } @Test(expectedExceptions = IOException.class, expectedExceptionsMessageRegExp = ".*no host.*") public void testUriWithoutHostIsRejected() throws Exception { ByteStringUtility.assertHostAllowed(new URI("http:///path")); } @DataProvider public Object[][] addressClassification() { return new Object[][] { // address, expected isInternalAddress { "8.8.8.8", false }, { "1.1.1.1", false }, { "203.0.113.10", false }, { "2001:4860:4860::8888", false }, { "127.0.0.1", true }, { "0.1.2.3", true }, { "169.254.169.254", true }, { "239.255.255.255", true }, // multicast { "255.255.255.255", true }, // broadcast // 100.64.0.0/10 boundaries { "100.63.255.255", false }, { "100.64.0.0", true }, { "100.100.100.200", true }, { "100.127.255.255", true }, { "100.128.0.0", false }, // 192.0.0.0/24 boundaries { "192.0.0.1", true }, { "192.0.1.1", false }, // 192.88.99.0/24 (deprecated 6to4 relay anycast, RFC 7526) boundaries { "192.88.98.255", false }, { "192.88.99.1", true }, { "192.88.100.0", false }, // 198.18.0.0/15 boundaries { "198.17.255.255", false }, { "198.18.0.0", true }, { "198.19.255.255", true }, { "198.20.0.0", false }, // 240.0.0.0/4 boundary { "239.0.0.1", true }, // multicast anyway { "240.0.0.0", true }, // IPv6 unique-local fc00::/7 boundaries { "fbff::1", false }, { "fc00::1", true }, { "fdff::1", true }, { "fe00::1", false }, // IPv6 transition addresses embedding an internal IPv4 (GHSA-97x5-8pm7-xfp7). { "64:ff9b::c0a8:0101", true }, // NAT64 well-known -> 192.168.1.1 { "64:ff9b::0a00:0001", true }, // NAT64 well-known -> 10.0.0.1 { "64:ff9b::7f00:0001", true }, // NAT64 well-known -> 127.0.0.1 { "64:ff9b::0808:0808", false }, // NAT64 well-known -> 8.8.8.8 (public) // The NAT64 local-use prefix 64:ff9b:1::/48 (RFC 8215) is blocked outright, // whichever RFC 6052 sub-prefix layout the operator picked inside it. { "64:ff9b:1::7f00:1", true }, // /96 layout -> 127.0.0.1 { "64:ff9b:1::a00:1", true }, // /96 layout -> 10.0.0.1 { "64:ff9b:1:abcd::c0a8:101", true }, // /96 layout in a subnet of the /48 -> 192.168.1.1 { "64:ff9b:1:0:c0:a801:100:0", true }, // /64 layout -> 192.168.1.1 { "64:ff9b:1:0:a:102:300:0", true }, // /64 layout -> 10.1.2.3 { "64:ff9b:1:7f00:0:100::", true }, // /48 layout -> 127.0.0.1 { "64:ff9b:1::808:808", true }, // 8.8.8.8: public, but not reachable through this prefix { "64:ff9b:1:ffff:ffff:ffff:ffff:ffff", true }, // no layout matches, still in the /48 // 64:ff9b:1::/48 boundaries. { "64:ff9b:0:1::7f00:1", false }, // 64:ff9b:0:1, not the local-use prefix { "64:ff9b:2::7f00:1", false }, // outside 64:ff9b::/96 and 64:ff9b:1::/48 { "64:ff9c:1::7f00:1", false }, // 64:ff9c:1, not the local-use prefix { "2002:0a00:0001::", true }, // 6to4 -> 10.0.0.1 { "2002:c0a8:0101::", true }, // 6to4 -> 192.168.1.1 { "2002:c058:6301::", true }, // 6to4 -> 192.88.99.1 (blocked IPv4 via transition) { "2002:0808:0808::", false }, // 6to4 -> 8.8.8.8 (public) { "2001:0000::3f57:fefe", true }, // Teredo -> 192.168.1.1 (obfuscated) { "2001:0000::f7f7:f7f7", false },// Teredo -> 8.8.8.8 (public, obfuscated) // A regular global-unicast address under 2001::/16 must not be misread as Teredo/6to4. { "2001:4860:4860::8844", false }, }; } @Test(dataProvider = "addressClassification") public void testIsInternalAddress(String literal, boolean expectedInternal) throws Exception { // IP literals do not trigger DNS resolution. InetAddress address = InetAddress.getByName(literal); assertEquals(ByteStringUtility.isInternalAddress(address), expectedInternal, "isInternalAddress(" + literal + ")"); } @Test public void testNat64NetworkSpecificPrefixIsNotBlockedByDefault() throws Exception { // An NSP is ordinary operator address space: without configuration it // cannot be told apart from a global address, so it must stay allowed. assertFalse(isInternal("2001:db8:122:344::c0a8:101")); } @Test public void testConfiguredNat64PrefixFiltersEmbeddedIPv4() throws Exception { ByteStringUtility.setNat64Prefixes("2001:db8:122:344::/96"); assertTrue(isInternal("2001:db8:122:344::c0a8:101")); // -> 192.168.1.1 assertTrue(isInternal("2001:db8:122:344::7f00:1")); // -> 127.0.0.1 assertFalse(isInternal("2001:db8:122:344::808:808")); // -> 8.8.8.8 (public) assertFalse(isInternal("2001:db8:122:345::c0a8:101")); // outside the prefix } @Test public void testConfiguredNat64PrefixWith64BitLength() throws Exception { // RFC 6052 /64 format: the IPv4 address sits in bytes 9..12, after the "u" byte. ByteStringUtility.setNat64Prefixes("2001:db8:122:344::/64"); assertTrue(isInternal("2001:db8:122:344:a:0:100:0")); // -> 10.0.0.1 assertFalse(isInternal("2001:db8:122:344:8:808:800:0"));// -> 8.8.8.8 (public) } @Test public void testMultipleNat64PrefixesAreSupported() throws Exception { ByteStringUtility.setNat64Prefixes(" 2001:db8:122:344::/96 , 2001:db8:9::/96 "); assertTrue(isInternal("2001:db8:122:344::a00:1")); // -> 10.0.0.1 assertTrue(isInternal("2001:db8:9::a9fe:a9fe")); // -> 169.254.169.254 assertFalse(isInternal("2001:db8:9::101:101")); // -> 1.1.1.1 (public) } @DataProvider public Object[][] invalidNat64Prefixes() { return new Object[][] { { "2001:db8::" }, // no /length { "2001:db8::/95" }, // not an RFC 6052 prefix length { "2001:db8::/abc" }, // length is not a number { "not-an-address/96" }, // not an IP literal (must not trigger DNS) { "192.0.2.0/96" }, // IPv4, not IPv6 }; } @Test(dataProvider = "invalidNat64Prefixes", expectedExceptions = IllegalArgumentException.class) public void testInvalidNat64PrefixesAreRejected(String prefixes) { ByteStringUtility.setNat64Prefixes(prefixes); } private boolean isInternal(String literal) throws Exception { return ByteStringUtility.isInternalAddress(InetAddress.getByName(literal)); } @DataProvider public Object[][] redirectCodes() { return new Object[][] { { 300 }, { 301 }, { 302 }, { 303 }, { 307 }, { 308 } }; } @Test(dataProvider = "redirectCodes", expectedExceptions = IOException.class, expectedExceptionsMessageRegExp = ".*redirect.*") public void testRedirectsAreRefused(int responseCode) throws Exception { ByteStringUtility.assertNotRedirect(responseCode, new URI("http://203.0.113.10/")); } @Test public void testNonRedirectCodesAreAccepted() throws Exception { ByteStringUtility.assertNotRedirect(200, new URI("http://203.0.113.10/")); ByteStringUtility.assertNotRedirect(404, new URI("http://203.0.113.10/")); } @Test public void testContentWithinCapIsReadFully() throws Exception { byte[] content = new byte[8192]; for (int i = 0; i < content.length; i++) { content[i] = (byte) i; } ByteStringUtility.setMaxUriContentLength(content.length); ByteString value = ByteStringUtility.readCapped( new ByteArrayInputStream(content), new URI("http://203.0.113.10/")); assertEquals(value, ByteString.wrap(content)); } @Test(expectedExceptions = IOException.class, expectedExceptionsMessageRegExp = ".*maximum allowed size.*") public void testContentOverCapIsRejected() throws Exception { ByteStringUtility.setMaxUriContentLength(1024); ByteStringUtility.readCapped( new ByteArrayInputStream(new byte[1025]), new URI("http://203.0.113.10/")); } @Test(expectedExceptions = IllegalArgumentException.class) public void testZeroMaxSizeIsRejected() { ByteStringUtility.setMaxUriContentLength(0); } @Test(expectedExceptions = IllegalArgumentException.class) public void testNegativeMaxSizeIsRejected() { 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 { ByteStringUtility.setDereferenceUri(true); // A disallowed scheme must be rejected even for an internal host, // and the internal host must be rejected for an allowed scheme. try { ByteStringUtility.convertUri(new URI("file://127.0.0.1/etc/passwd")); assertFalse(true, "expected IOException for disallowed scheme"); } catch (IOException e) { assertTrue(e.getMessage().contains("disallowed scheme"), e.getMessage()); } try { ByteStringUtility.convertUri(new URI("http://127.0.0.1/")); assertFalse(true, "expected IOException for internal address"); } catch (IOException e) { assertTrue(e.getMessage().contains("internal"), e.getMessage()); } } }