Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -17,17 +17,92 @@
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,16 @@ public String getRemoteHost()
return "localhost";
}

/**
* Always returns "127.0.0.1".
*
* @since 5.10
*/
public String getRemoteAddr()
{
return "127.0.0.1";
}

public boolean isSessionInvalidated()
{
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ public String getRemoteHost()
return request.getRemoteHost();
}

public String getRemoteAddr()
{
return request.getRemoteAddr();
}

public boolean isSessionInvalidated()
{
return request.isSessionInvalidated();
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ public String getRemoteHost()
return request.getRemoteHost();
}

public String getRemoteAddr()
{
return request.getRemoteAddr();
}

/**
* Converts an enumeration (of Strings) into a sorted list of Strings.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,17 @@ 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.0
*/
default String getRemoteAddr() {
return null;
}

/**
* Returns true if the request specified a session, and that session has been invalidated.
*
Expand Down