This is an automated email from the ASF dual-hosted git repository.

coheigea pushed a commit to branch coheigea/reference-pinning
in repository https://gitbox.apache.org/repos/asf/ws-neethi.git

commit 3435acf9f346784ca5840e28366fefd6435c3da6
Author: Colm O hEigeartaigh <[email protected]>
AuthorDate: Fri Aug 21 06:41:30 2026 +0100

    Check all DNS entries for the host to see if they are allowed and pin the 
address used
---
 .../java/org/apache/neethi/PolicyReference.java    | 62 +++++++++++++++++--
 .../neethi/PolicyReferenceAddressFilterTest.java   | 71 ++++++++++++++++++++++
 2 files changed, 127 insertions(+), 6 deletions(-)

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 class PolicyReference implements PolicyComponent {
             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 @@ public class PolicyReference implements PolicyComponent {
         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

Reply via email to