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

benweidig pushed a commit to branch TAP5-2832
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git

commit 0002d4e1991147f3d444c1c7032eace2e0cc6728
Author: Ben Weidig <[email protected]>
AuthorDate: Sun Jun 28 10:37:15 2026 +0200

    TAP5-2832: LocalhostOnly detect IPv6 correctly
---
 .../internal/services/security/LocalhostOnly.java  |  83 +++++++++++++++-
 .../internal/test/TestableRequestImpl.java         |  10 ++
 .../tapestry5/services/DelegatingRequest.java      |   5 +
 .../services/security/LocalhostOnlyTest.java       | 104 +++++++++++++++++++++
 .../http/internal/services/RequestImpl.java        |   5 +
 .../apache/tapestry5/http/services/Request.java    |   9 ++
 6 files changed, 212 insertions(+), 4 deletions(-)

diff --git 
a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/security/LocalhostOnly.java
 
b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/security/LocalhostOnly.java
index e5c2871f6..946ab1aa1 100644
--- 
a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/security/LocalhostOnly.java
+++ 
b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/security/LocalhostOnly.java
@@ -1,4 +1,4 @@
-// Copyright 2011, 2012 The Apache Software Foundation
+// Copyright 2011, 2012, 2026 The Apache Software Foundation
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -17,17 +17,92 @@ package org.apache.tapestry5.internal.services.security;
 import org.apache.tapestry5.http.services.Request;
 import org.apache.tapestry5.services.security.WhitelistAnalyzer;
 
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.Set;
+
 /**
- * Standard analyzer that places requests from the "localhost", "127.0.0.1", 
"0:0:0:0:0:0:0:1%0", or :"0:0:0:0:0:0:0:1" onto the white list.
+ * Standard analyzer that places requests from loopback addresses onto the 
whitelist.
+ * Recognized forms include IPv4 (127.x.x.x), IPv6 (::1, 0:0:0:0:0:0:0:1), 
compressed
+ * or with arbitrary scope IDs (e.g. ::1%lo, 0:0:0:0:0:0:0:1%eth0), and 
IPv4-mapped
+ * IPv6 loopback (::ffff:127.0.0.1).
  *
  * @since 5.3
  */
 public class LocalhostOnly implements WhitelistAnalyzer
 {
+    // Common loopback address literals that need no parsing.
+    // Adding "localhost" avoids a potential hostname lookup
+    // via InetAddress.getByName().
+    private static final Set<String> LOOPBACK_LITERALS = Set.of(
+        "localhost",
+        "127.0.0.1",
+        "::1",
+        "0:0:0:0:0:0:0:1"
+    );
+
     public boolean isRequestOnWhitelist(Request request)
     {
-        String remoteHost = request.getRemoteHost();
+        String remoteAddr = request.getRemoteAddr();
+
+        // Fastpath
+        if (LOOPBACK_LITERALS.contains(remoteAddr))
+        {
+            return true;
+        }
+
+        // Strip IPv6 scope ID (e.g. ::1%lo, 0:0:0:0:0:0:0:1%eth0) before 
parsing
+        int percentIdx = remoteAddr.indexOf('%');
+        String addr = (percentIdx >= 0) ? remoteAddr.substring(0, percentIdx) 
: remoteAddr;
+
+        try
+        {
+            InetAddress inetAddress = InetAddress.getByName(addr);
+
+            if (inetAddress.isLoopbackAddress())
+                return true;
+
+            // InetAddress.isLoopbackAddress() returns false for IPv4-mapped 
IPv6 loopback
+            // (e.g. ::ffff:127.0.0.1). Extract the embedded IPv4 address and 
check that instead.
+            if (inetAddress instanceof Inet6Address)
+            {
+                byte[] bytes = inetAddress.getAddress();
+                if (isIPv4MappedAddress(bytes))
+                {
+                    InetAddress embedded = InetAddress.getByAddress(new 
byte[]{ bytes[12], bytes[13], bytes[14], bytes[15] });
+                    return embedded.isLoopbackAddress();
+                }
+            }
+
+            return false;
+        }
+        catch (UnknownHostException e)
+        {
+            return false;
+        }
+    }
+
+    // IPv4-mapped IPv6 addresses have the form ::ffff:a.b.c.d, encoded as 16 
bytes:
+    //  - bytes  0–9:  all zero
+    //  - bytes 10–11: 0xff 0xff  (the "mapped" marker)
+    //  - bytes 12–15: the IPv4 address
+    private static boolean isIPv4MappedAddress(byte[] bytes)
+    {
+        if (bytes.length != 16)
+        {
+            return false;
+        }
+
+        // Bytes 0–9 must all be zero
+        for (int i = 0; i < 10; i++)
+        {
+            if (bytes[i] != 0) {
+                return false;
+            }
+        }
 
-        return remoteHost.equals("localhost") || 
remoteHost.equals("127.0.0.1") || remoteHost.equals("0:0:0:0:0:0:0:1%0") || 
remoteHost.equals("0:0:0:0:0:0:0:1");
+        // Bytes 10–11 must be 0xff 0xff
+        return bytes[10] == (byte) 0xff && bytes[11] == (byte) 0xff;
     }
 }
diff --git 
a/tapestry-core/src/main/java/org/apache/tapestry5/internal/test/TestableRequestImpl.java
 
b/tapestry-core/src/main/java/org/apache/tapestry5/internal/test/TestableRequestImpl.java
index 11016ba63..ef218091b 100644
--- 
a/tapestry-core/src/main/java/org/apache/tapestry5/internal/test/TestableRequestImpl.java
+++ 
b/tapestry-core/src/main/java/org/apache/tapestry5/internal/test/TestableRequestImpl.java
@@ -264,6 +264,16 @@ public class TestableRequestImpl implements TestableRequest
         return "localhost";
     }
 
+    /**
+     * Always returns "127.0.0.1".
+     *
+     * @since 5.10
+     */
+    public String getRemoteAddr()
+    {
+        return "127.0.0.1";
+    }
+
     public boolean isSessionInvalidated()
     {
         return false;
diff --git 
a/tapestry-core/src/main/java/org/apache/tapestry5/services/DelegatingRequest.java
 
b/tapestry-core/src/main/java/org/apache/tapestry5/services/DelegatingRequest.java
index aeb1c5ea0..75601c67f 100644
--- 
a/tapestry-core/src/main/java/org/apache/tapestry5/services/DelegatingRequest.java
+++ 
b/tapestry-core/src/main/java/org/apache/tapestry5/services/DelegatingRequest.java
@@ -166,6 +166,11 @@ public class DelegatingRequest implements Request
         return request.getRemoteHost();
     }
 
+    public String getRemoteAddr()
+    {
+        return request.getRemoteAddr();
+    }
+
     public boolean isSessionInvalidated()
     {
         return request.isSessionInvalidated();
diff --git 
a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/security/LocalhostOnlyTest.java
 
b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/security/LocalhostOnlyTest.java
new file mode 100644
index 000000000..a617f59e0
--- /dev/null
+++ 
b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/security/LocalhostOnlyTest.java
@@ -0,0 +1,104 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.internal.services.security;
+
+import org.apache.tapestry5.http.services.Request;
+import org.easymock.EasyMock;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+// TAP5-2832
+class LocalhostOnlyTest
+{
+    static Stream<String> whitelist_addresses()
+    {
+        return Stream.of(
+            // Standard loopback
+            "localhost",
+            "127.0.0.1",
+
+            // Full-form IPv6 loopback
+            "0:0:0:0:0:0:0:1",
+            "0:0:0:0:0:0:0:1%0",
+
+            // Compressed IPv6 loopback
+            "::1",
+
+            // IPv6 loopback with arbitrary scope IDs (OS-dependent: Linux 
uses %lo, Windows uses numeric IDs)
+            "::1%lo",
+            "::1%eth0",
+            "::1%12",
+            "0:0:0:0:0:0:0:1%lo",
+            "0:0:0:0:0:0:0:1%eth0",
+
+            // IPv4-mapped IPv6 loopback (dual-stack environments)
+            "::ffff:127.0.0.1",
+            "::ffff:7f00:1"
+        );
+    }
+
+    private Request mockRequest(String remoteAddr)
+    {
+        Request request = EasyMock.mock(Request.class);
+        EasyMock.expect(request.getRemoteAddr()).andReturn(remoteAddr);
+        EasyMock.replay(request);
+        return request;
+    }
+
+    @ParameterizedTest
+    @MethodSource("whitelist_addresses")
+    void localhost_address_is_on_whitelist(String remoteAddr)
+    {
+        // ARRANGE
+        Request request = mockRequest(remoteAddr);
+
+        // ACT
+        boolean result = new LocalhostOnly().isRequestOnWhitelist(request);
+
+        // ASSERT
+        assertTrue(result, "Expected '" + remoteAddr + "' to be on the 
whitelist");
+    }
+
+    static Stream<String> non_whitelist_addresses()
+    {
+        return Stream.of(
+            "192.168.1.1",
+            "10.0.0.1",
+            "172.16.0.1",
+            "remotehost.example.com",
+            "2001:db8::1",
+            "fe80::1",
+            "::2"
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("non_whitelist_addresses")
+    void non_localhost_address_is_not_on_whitelist(String remoteAddr)
+    {
+        // ARRANGE
+        Request request = mockRequest(remoteAddr);
+
+        // ACT
+        boolean result = new LocalhostOnly().isRequestOnWhitelist(request);
+
+        // ASSERT
+        assertFalse(result, "Expected '" + remoteAddr + "' to be NOT on the 
whitelist");
+    }
+}
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/RequestImpl.java
 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/RequestImpl.java
index 87e4ad4d4..40473cfb2 100644
--- 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/RequestImpl.java
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/internal/services/RequestImpl.java
@@ -238,6 +238,11 @@ public class RequestImpl implements Request
         return request.getRemoteHost();
     }
 
+    public String getRemoteAddr()
+    {
+        return request.getRemoteAddr();
+    }
+
     /**
      * Converts an enumeration (of Strings) into a sorted list of Strings.
      */
diff --git 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/services/Request.java 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/Request.java
index 43564d953..cc1776f94 100644
--- 
a/tapestry-http/src/main/java/org/apache/tapestry5/http/services/Request.java
+++ 
b/tapestry-http/src/main/java/org/apache/tapestry5/http/services/Request.java
@@ -209,6 +209,15 @@ public interface Request
      */
     String getRemoteHost();
 
+    /**
+     * Returns the IP address of the client or last proxy that sent the 
request,
+     * always as a numeric address string (never a hostname).
+     *
+     * @return a <code>String</code> containing the IP address of the client 
that sent the request
+     * @since 5.10
+     */
+    String getRemoteAddr();
+
     /**
      * Returns true if the request specified a session, and that session has 
been invalidated.
      *

Reply via email to