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
Expand Up @@ -39,6 +39,9 @@ public class WebClientUtils {

private static final InetAddressValidator inetAddressValidator = InetAddressValidator.getInstance();

// Not final so that tests can simulate Docker environment via reflection
private static boolean IN_DOCKER = "1".equals(System.getenv("IN_DOCKER"));

private static final Set<String> DISALLOWED_HOSTS = computeDisallowedHosts();

public static final String HOST_NOT_ALLOWED = "Host not allowed.";
Expand Down Expand Up @@ -80,7 +83,7 @@ private static Set<String> computeDisallowedHosts() {
"metadata.google.internal",
"metadata.tencentyun.com");

if ("1".equals(System.getenv("IN_DOCKER"))) {
if (IN_DOCKER) {
addDisallowedHosts(hosts, "127.0.0.1", "::1");
}

Expand Down Expand Up @@ -233,16 +236,8 @@ public static Optional<InetAddress> resolveIfAllowed(String host) {
if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(addr.getHostAddress()))) {
return Optional.empty();
}
if (addr instanceof Inet6Address) {
byte firstByte = addr.getAddress()[0];
if ((firstByte & (byte) 0xFE) == (byte) 0xFC) {
return Optional.empty();
}
}
if (addr.isLoopbackAddress()
|| addr.isLinkLocalAddress()
|| addr.isAnyLocalAddress()
|| addr.isMulticastAddress()) {
// SMTP path always blocks loopback, regardless of IN_DOCKER
if (addr.isLoopbackAddress() || isBlockedByAddressType(addr)) {
return Optional.empty();
}
}
Expand All @@ -251,7 +246,7 @@ public static Optional<InetAddress> resolveIfAllowed(String host) {
}

public static boolean isDisallowedAndFail(String host, Promise<?> promise) {
if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(host))) {
if (isHostDisallowed(host)) {
log.warn("Host {} is disallowed. Failing the request.", host);
if (promise != null) {
promise.setFailure(new UnknownHostException(HOST_NOT_ALLOWED));
Expand All @@ -261,6 +256,59 @@ public static boolean isDisallowedAndFail(String host, Promise<?> promise) {
return false;
}

/**
* Checks whether a host (IP literal or hostname) should be blocked. Combines the
* static denylist with runtime address-type checks (loopback, link-local, any-local,
* multicast, IPv6 ULA) so that the full 127.0.0.0/8 range and equivalent addresses
* are rejected regardless of the exact string representation.
*/
private static boolean isHostDisallowed(String host) {
final String canonicalHost = normalizeHostForComparisonQuietly(host);

if (DISALLOWED_HOSTS.contains(canonicalHost)) {
return true;
}

if (isValidIpAddress(canonicalHost)) {
try {
final InetAddress addr = InetAddress.getByName(canonicalHost);
if (isBlockedByAddressType(addr)) {
return true;
}
} catch (UnknownHostException e) {
// Should not happen for a valid IP literal; fall through.
}
}

return false;
}

/**
* Returns {@code true} if the given address belongs to a category that must never be
* reached by user-controlled outbound requests. Loopback blocking (entire 127.0.0.0/8
* and ::1) is only active inside Docker, consistent with the existing DISALLOWED_HOSTS
* policy. Link-local, wildcard/any-local, multicast, and IPv6 Unique Local (fc00::/7)
* are always blocked.
*/
private static boolean isBlockedByAddressType(InetAddress addr) {
if (IN_DOCKER && addr.isLoopbackAddress()) {
return true;
}

if (addr.isLinkLocalAddress() || addr.isAnyLocalAddress() || addr.isMulticastAddress()) {
return true;
}

if (addr instanceof Inet6Address) {
byte firstByte = addr.getAddress()[0];
if ((firstByte & (byte) 0xFE) == (byte) 0xFC) {
return true; // IPv6 Unique Local Address (fc00::/7)
}
}

return false;
}

private static Mono<ClientRequest> requestFilterFn(ClientRequest request) {
final String host = request.url().getHost();

Expand All @@ -279,7 +327,7 @@ private static Mono<ClientRequest> requestFilterFn(ClientRequest request) {
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, "IP Address resolution is invalid"));
}

return DISALLOWED_HOSTS.contains(canonicalHost)
return isHostDisallowed(canonicalHost)
? Mono.error(new UnknownHostException(HOST_NOT_ALLOWED))
: Mono.just(request);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.appsmith.util;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
Expand All @@ -8,6 +10,7 @@
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.URI;
Expand All @@ -19,6 +22,23 @@

public class WebClientUtilsTest {

private static boolean originalInDocker;

@BeforeAll
static void enableDockerMode() throws Exception {
Field field = WebClientUtils.class.getDeclaredField("IN_DOCKER");
field.setAccessible(true);
originalInDocker = field.getBoolean(null);
field.setBoolean(null, true);
}

@AfterAll
static void restoreDockerMode() throws Exception {
Field field = WebClientUtils.class.getDeclaredField("IN_DOCKER");
field.setAccessible(true);
field.setBoolean(null, originalInDocker);
}

@ParameterizedTest
@ValueSource(
strings = {
Expand Down Expand Up @@ -81,6 +101,47 @@ public void resolveIfAllowed_allowsLegitimateSmtpHosts(String host) {
assertTrue(result.isPresent(), "Expected host " + host + " to be allowed");
}

@ParameterizedTest
@ValueSource(
strings = {
"http://127.0.0.2:9001/RPC2",
"http://127.0.1.1:9001/RPC2",
"http://127.255.255.254:9001/RPC2",
"http://0.0.0.0:9001/RPC2",
})
public void testRequestFilterFnRejectsFullLoopbackRange(String url) throws Exception {
StepVerifier.create(invokeRequestFilterFn(url))
.expectErrorSatisfies(throwable -> {
assertTrue(throwable instanceof UnknownHostException);
assertEquals(WebClientUtils.HOST_NOT_ALLOWED, throwable.getMessage());
})
.verify();
}

@ParameterizedTest
@ValueSource(
strings = {
"127.0.0.2",
"127.0.1.1",
"127.255.255.254",
})
public void testIsDisallowedAndFailBlocksFullLoopbackRange(String host) {
assertTrue(
WebClientUtils.isDisallowedAndFail(host, null), "Expected loopback address " + host + " to be blocked");
}

@ParameterizedTest
@ValueSource(
strings = {
"127.0.0.2",
"127.0.1.1",
"127.255.255.254",
})
public void resolveIfAllowed_blocksFullLoopbackRange(String host) {
Optional<InetAddress> result = WebClientUtils.resolveIfAllowed(host);
assertTrue(result.isEmpty(), "Expected loopback address " + host + " to be blocked");
}

@Test
public void resolveIfAllowed_blocksUnresolvableHost() {
Optional<InetAddress> result = WebClientUtils.resolveIfAllowed("definitely-not-a-real-host-xyz123.invalid");
Expand Down
Loading