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