From 3435acf9f346784ca5840e28366fefd6435c3da6 Mon Sep 17 00:00:00 2001 From: Colm O hEigeartaigh Date: Fri, 21 Aug 2026 06:41:30 +0100 Subject: [PATCH] Check all DNS entries for the host to see if they are allowed and pin the address used --- .../org/apache/neethi/PolicyReference.java | 62 ++++++++++++++-- .../PolicyReferenceAddressFilterTest.java | 71 +++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 src/test/java/org/apache/neethi/PolicyReferenceAddressFilterTest.java diff --git a/src/main/java/org/apache/neethi/PolicyReference.java b/src/main/java/org/apache/neethi/PolicyReference.java index 81dfd33..497de26 100644 --- a/src/main/java/org/apache/neethi/PolicyReference.java +++ b/src/main/java/org/apache/neethi/PolicyReference.java @@ -40,6 +40,13 @@ public class PolicyReference implements PolicyComponent { public static final String MAX_REMOTE_POLICY_BYTES_PROPERTY = "org.apache.neethi.remote.maxPolicyBytes"; public static final String MAX_REMOTE_FETCH_MILLIS_PROPERTY = "org.apache.neethi.remote.maxFetchMillis"; + /** + * Set to "false" to disable rewriting http URLs to the vetted literal + * address (see getRemoteReferencedPolicy). Pinning is on by default; the + * opt-out exists for deployments that rely on name-based virtual hosting + * for http policy endpoints and accept the DNS-rebinding residual risk. + */ + public static final String PIN_ADDRESS_PROPERTY = "org.apache.neethi.remote.pinAddress"; private static final long DEFAULT_MAX_REMOTE_POLICY_BYTES = 64L * 1024L * 1024L; private static final long DEFAULT_MAX_REMOTE_FETCH_MILLIS = 30L * 1000L; @@ -168,24 +175,49 @@ public Policy getRemoteReferencedPolicy(String u) { throw new RuntimeException("Unsupported URI scheme: only http and https are permitted."); } - // Resolve the host to an IP and reject addresses that can never serve a policy document: + // Resolve the host and reject addresses that can never serve a policy document: // - link-local (169.254.x.x / fe80::/10) — cloud IMDS, auto-configuration // - multicast (224.0.0.0/4 / ff00::/8) — no HTTP server listens at a multicast address // - any-local (0.0.0.0 / ::) — unspecified/wildcard, not a valid destination // Loopback (127.x.x.x / ::1) and site-local (RFC-1918) addresses are permitted // so that policies on localhost or an internal network can be resolved. + // EVERY address the host resolves to is vetted, so a multi-record DNS + // answer cannot smuggle a forbidden address past the filter. + InetAddress[] addresses; try { - InetAddress addr = InetAddress.getByName(url.getHost()); - if (addr.isLinkLocalAddress() || addr.isMulticastAddress() || addr.isAnyLocalAddress()) { + addresses = InetAddress.getAllByName(url.getHost()); + } catch (UnknownHostException e) { + throw new RuntimeException("PolicyReference URI host could not be resolved."); + } + for (InetAddress addr : addresses) { + if (isForbiddenAddress(addr)) { throw new RuntimeException( "PolicyReference URI resolves to a forbidden address (link-local, multicast, or wildcard)."); } - } catch (UnknownHostException e) { - throw new RuntimeException("PolicyReference URI host could not be resolved."); + } + + // Pin the connection to an address that was actually vetted: + // URLConnection re-resolves the hostname at connect time, which opens + // a DNS-rebinding TOCTOU window between the check above and the + // connect. For http the URL host is rewritten to the vetted literal + // address (note: the JDK will send that literal in the Host header - + // see PIN_ADDRESS_PROPERTY to opt out for name-based virtual + // hosting). For https the hostname is kept: TLS certificate + // verification against the original hostname binds the peer identity, + // and a literal-address URL would break it. + URL connectionUrl = url; + if ("http".equalsIgnoreCase(scheme) + && !"false".equalsIgnoreCase(System.getProperty(PIN_ADDRESS_PROPERTY))) { + try { + connectionUrl = new URL(url.getProtocol(), toUrlHost(addresses[0]), + url.getPort(), url.getFile()); + } catch (MalformedURLException mue) { + throw new RuntimeException("Malformed uri."); + } } try { - URLConnection connection = url.openConnection(); + URLConnection connection = connectionUrl.openConnection(); connection.setDoInput(true); connection.setConnectTimeout(5000); connection.setReadTimeout(10000); @@ -250,6 +282,24 @@ static byte[] readBounded(InputStream input, long maxBytes, return out.toByteArray(); } + /** + * Returns whether the resolved address belongs to a class the reference + * fetcher must never connect to. Package-private for tests. + */ + static boolean isForbiddenAddress(InetAddress addr) { + return addr.isLinkLocalAddress() || addr.isMulticastAddress() || addr.isAnyLocalAddress(); + } + + private static String toUrlHost(InetAddress addr) { + String literal = addr.getHostAddress(); + int scope = literal.indexOf('%'); + if (scope >= 0) { + // an IPv6 scope id is not valid in a URL host + literal = literal.substring(0, scope); + } + return literal.indexOf(':') >= 0 ? "[" + literal + "]" : literal; + } + private static long readConfiguredLimit(String key, long defaultValue) { String value = System.getProperty(key); if (value == null || value.trim().length() == 0) { diff --git a/src/test/java/org/apache/neethi/PolicyReferenceAddressFilterTest.java b/src/test/java/org/apache/neethi/PolicyReferenceAddressFilterTest.java new file mode 100644 index 0000000..13ae9f3 --- /dev/null +++ b/src/test/java/org/apache/neethi/PolicyReferenceAddressFilterTest.java @@ -0,0 +1,71 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.neethi; + +import java.net.InetAddress; + +import org.junit.Test; + +/** + * Address-class checks for the remote policy fetcher. All literals resolve + * without DNS. The forbidden classes are link-local (cloud IMDS), multicast, + * and any-local; loopback and RFC-1918 stay permitted by documented intent. + */ +public class PolicyReferenceAddressFilterTest extends PolicyTestCase { + + @Test + public void testLinkLocalIsForbidden() throws Exception { + assertTrue(PolicyReference.isForbiddenAddress(InetAddress.getByName("169.254.169.254"))); + assertTrue(PolicyReference.isForbiddenAddress(InetAddress.getByName("fe80::1"))); + } + + @Test + public void testMulticastIsForbidden() throws Exception { + assertTrue(PolicyReference.isForbiddenAddress(InetAddress.getByName("224.0.0.1"))); + assertTrue(PolicyReference.isForbiddenAddress(InetAddress.getByName("ff02::1"))); + } + + @Test + public void testAnyLocalIsForbidden() throws Exception { + assertTrue(PolicyReference.isForbiddenAddress(InetAddress.getByName("0.0.0.0"))); + assertTrue(PolicyReference.isForbiddenAddress(InetAddress.getByName("::"))); + } + + @Test + public void testLoopbackAndPrivateRangesStayPermitted() throws Exception { + assertFalse(PolicyReference.isForbiddenAddress(InetAddress.getByName("127.0.0.1"))); + assertFalse(PolicyReference.isForbiddenAddress(InetAddress.getByName("::1"))); + assertFalse(PolicyReference.isForbiddenAddress(InetAddress.getByName("10.0.0.5"))); + assertFalse(PolicyReference.isForbiddenAddress(InetAddress.getByName("192.168.1.10"))); + } + + @Test + public void testPublicAddressStaysPermitted() throws Exception { + assertFalse(PolicyReference.isForbiddenAddress(InetAddress.getByName("93.184.216.34"))); + } + + @Test + public void testIpv4MappedEncodingOfLinkLocalIsForbidden() throws Exception { + // getByName normalizes ::ffff:a.b.c.d to Inet4Address, so the mapped + // encoding of the IMDS address classifies as link-local and is caught + assertTrue(PolicyReference.isForbiddenAddress( + InetAddress.getByName("::ffff:169.254.169.254"))); + } +} \ No newline at end of file