Skip to content

Commit 48ac053

Browse files
wyattwalterclaude
andauthored
fix(security): block Redis datasource from internal Appsmith Redis (GHSA-qhfj-g87x-m39w) (#41921)
## Summary Closes [GHSA-qhfj-g87x-m39w](GHSA-qhfj-g87x-m39w). The Redis datasource plugin opened a Jedis connection to a user-controlled host/port with no IP-class filtering, so a user with datasource-create permission could point a Redis datasource at the internal Appsmith Redis. The existing HTTP-side filter doesn't cover Redis — Jedis uses the JVM's default resolver and bypasses the Reactor Netty hook. This PR also closes a DNS-rebinding TOCTOU on the same path and enforces TLS certificate hostname verification for `rediss://` datasources (items 4 and 5 below). ## Changes 1. **Block Redis datasources from reaching the internal Appsmith Redis.** `RedisPlugin.datasourceCreate` now consults a shared `RestrictedHostFilter`. Blocks literal hostname / IP matches against `APPSMITH_REDIS_URL` and `APPSMITH_REDIS_GIT_URL`, plus loopback / link-local / cloud-metadata literals. Enforcement is connection-time only — `PluginExecutor.validateDatasource` is documented format-only, so host-policy belongs on the connection path. UX: Save succeeds, "Test Datasource" rejects with `Host not allowed.`. 2. **Make SSRF protections always-on; rename `IN_DOCKER` to `APPSMITH_DISABLE_SSRF_FILTER`.** The previous filter gated the loopback / link-local block on `IN_DOCKER=1`, which Appsmith's official Docker image always sets — so users on documented deployment paths were already getting this protection. This change closes the gap for unofficial distributions (self-built images, bare-metal, etc.) and replaces a poorly-named variable with one that actually describes what it does. The most likely place to hit this is a development machine running the server directly against `http://localhost:*` — set `APPSMITH_DISABLE_SSRF_FILTER=true` to allow it. 3. **Extract `RestrictedHostFilter` from `WebClientUtils`.** Pure refactor — the host-filter logic had already grown several non-HTTP call sites and the class name had outgrown its scope. 4. **Close the DNS-rebinding TOCTOU on the Redis path (upgrade Jedis 3.3.0 → 5.2.0 + pin the resolved IP).** The create-time pre-check in (1) and Jedis's own connection-time lookup resolved the hostname independently, so a hostile resolver could return an allowed IP to the check and the internal Redis IP to the driver. Jedis 5.2.0 (up from 3.3.0, which predated the API) allows injecting a `JedisSocketFactory`; the new `RestrictedHostJedisSocketFactory` resolves once at connect time, validates every resolved address, and connects directly to the pinned IP — so the driver never re-resolves and the window is closed. The create-time check stays as fast Save/Test UX and now logs the block at WARN, tagged with the requesting user/org/trace (a useful reconnaissance signal). Jedis is plugin-isolated (PF4J + shaded); the server's own Redis runs on Lettuce and is untouched. 5. **Enforce TLS certificate hostname verification for `rediss://` datasources.** Jedis 5.2.0 validates the certificate trust chain but does not verify that the cert's SAN/CN matches the host (it only does so by default in 7.5.0+), so a man-in-the-middle presenting any CA-trusted-but-mismatched cert on the pinned IP would have been accepted. The socket factory now sets the endpoint identification algorithm to `HTTPS`, verifying against the configured hostname (we connect to the pinned IP but hand the original hostname to the TLS layer for SNI and verification). This was a pre-existing gap, not introduced here; closing it now is low-risk because Redis TLS support is recent (#41587, ~2026-03) and barely adopted, and chain validation was already on — so only CA-trusted-but-mismatched certs and IP-addressed `rediss://` (certs rarely carry IP SANs) are newly rejected. 6. **Housekeeping: drop dead `IN_DOCKER` references from Dockerfile / deploy scripts / CI workflow.** No consumer left after the rename in (2). /ok-to-test tags="@tag.All" ## Test plan - [x] CI green across all commits (Jedis 5.2.0 upgrade, socket factory, TLS verification, logging). - [x] Deploy preview: Redis datasource at \`localhost\` / in-cluster Redis service DNS — Save accepted, Test rejected with \`Host not allowed.\`. Legitimate external Redis works. - [x] Connect-time SSRF rejection (the rebinding gate) covered by `RedisPluginTest` (`socketFactory_blockedHost_failsAtConnectTime`). A live end-to-end rebinding flip needs a changing resolver and was not exercised on the DP (rbndr.us is unreachable from AWS). - [x] Confirm the one existing `rediss://` user (Appsmith internal) connects via a hostname matching its cert, now that hostname verification is enforced. - [x] Dev environment: confirm \`APPSMITH_DISABLE_SSRF_FILTER=true\` unblocks \`http://localhost:*\` datasources during local development. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: Cypress test results --> > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: <https://github.com/appsmithorg/appsmith/actions/runs/28840292682> > Commit: c795905 > <a href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=28840292682&attempt=2" target="_blank">Cypress dashboard</a>. > Tags: `@tag.All` > Spec: > <hr>Tue, 07 Jul 2026 13:31:52 UTC <!-- end of auto-generated comment: Cypress test results --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added centralized SSRF/restricted host filtering with denylisting and normalization. * Enabled consistent enforcement across outbound web requests, Elasticsearch DNS resolution, SMTP test-email, and Redis datasource connections. * Mitigated Redis DNS rebinding by validating once and pinning the resolved address. * **Bug Fixes** * Blocked hosts now fail with a consistent “Host not allowed” error across execution paths. * **Tests** * Added dedicated host/IP blocking test suite. * Updated integration tests to explicitly control SSRF filtering enablement and state restoration during runs. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7491d32 commit 48ac053

21 files changed

Lines changed: 1961 additions & 636 deletions

File tree

.github/workflows/on-demand-build-docker-image-deploy-preview.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,6 @@ jobs:
328328
APPSMITH_AI_SERVER_MANAGED_HOSTING: ${{ secrets.APPSMITH_AI_SERVER_MANAGED_HOSTING }}
329329
APPSMITH_BETTERBUGS_API_KEY: ${{ secrets.APPSMITH_BETTERBUGS_API_KEY }}
330330
APPSMITH_PYLON_APP_ID: ${{ secrets.APPSMITH_PYLON_APP_ID }}
331-
IN_DOCKER: ${{ secrets.IN_DOCKER }}
332331
run: |
333332
echo "environment variables set to deploy the image" $IMAGE_HASH
334333
/bin/bash ./scripts/deploy_preview.sh

Dockerfile

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
ARG BASE
22
FROM ${BASE}
33

4-
ENV IN_DOCKER=1
5-
64
ARG APPSMITH_CLOUD_SERVICES_BASE_URL
75
ENV APPSMITH_CLOUD_SERVICES_BASE_URL=${APPSMITH_CLOUD_SERVICES_BASE_URL}
86

app/server/appsmith-interfaces/src/main/java/com/appsmith/util/RestrictedHostFilter.java

Lines changed: 606 additions & 0 deletions
Large diffs are not rendered by default.

app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java

Lines changed: 22 additions & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import io.netty.util.concurrent.Promise;
1111
import io.netty.util.internal.SocketUtils;
1212
import lombok.extern.slf4j.Slf4j;
13-
import org.apache.commons.validator.routines.InetAddressValidator;
1413
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
1514
import org.springframework.util.StringUtils;
1615
import org.springframework.web.reactive.function.client.ClientRequest;
@@ -21,28 +20,32 @@
2120
import reactor.netty.http.client.HttpClient;
2221
import reactor.netty.resources.ConnectionProvider;
2322

24-
import java.net.Inet6Address;
2523
import java.net.InetAddress;
2624
import java.net.InetSocketAddress;
2725
import java.net.UnknownHostException;
2826
import java.time.Duration;
2927
import java.util.Arrays;
30-
import java.util.Collections;
31-
import java.util.HashSet;
3228
import java.util.List;
33-
import java.util.Locale;
34-
import java.util.Optional;
35-
import java.util.Set;
3629

30+
/**
31+
* Factory for {@link WebClient} instances pre-wired with the SSRF host filter
32+
* ({@link RestrictedHostFilter}). The filter is enforced at two layers:
33+
*
34+
* <ol>
35+
* <li>A request-stage {@link ExchangeFilterFunction} ({@link #IP_CHECK_FILTER}) that
36+
* rejects literal/canonical hosts on the deny set before DNS even runs.</li>
37+
* <li>A custom Netty {@link AddressResolver} ({@link ResolverGroup}) that runs DNS
38+
* itself and rejects when any resolved address lands on the deny set or matches a
39+
* non-routable address class.</li>
40+
* </ol>
41+
*
42+
* <p>Host-filter logic lives in {@link RestrictedHostFilter} so non-HTTP plugins (Redis,
43+
* SMTP, the Elasticsearch HttpAsyncClient hook, etc.) can call into the same denylist
44+
* without depending on Spring/Netty.
45+
*/
3746
@Slf4j
3847
public class WebClientUtils {
3948

40-
private static final InetAddressValidator inetAddressValidator = InetAddressValidator.getInstance();
41-
42-
private static final Set<String> DISALLOWED_HOSTS = computeDisallowedHosts();
43-
44-
public static final String HOST_NOT_ALLOWED = "Host not allowed.";
45-
4649
private static final int MAX_IN_MEMORY_SIZE_IN_BYTES = 16 * 1024 * 1024;
4750

4851
public static final ExchangeFilterFunction IP_CHECK_FILTER =
@@ -66,37 +69,6 @@ public class WebClientUtils {
6669

6770
private WebClientUtils() {}
6871

69-
private static Set<String> computeDisallowedHosts() {
70-
final Set<String> hosts = new HashSet<>();
71-
addDisallowedHosts(
72-
hosts,
73-
"169.254.169.254",
74-
"168.63.129.16",
75-
"fd00:ec2::254",
76-
"fd20:ce::254",
77-
"100.100.100.200",
78-
"169.254.10.10",
79-
"169.254.170.2",
80-
"metadata.google.internal",
81-
"metadata.tencentyun.com");
82-
83-
if ("1".equals(System.getenv("IN_DOCKER"))) {
84-
addDisallowedHosts(hosts, "127.0.0.1", "::1");
85-
}
86-
87-
return Collections.unmodifiableSet(hosts);
88-
}
89-
90-
private static void addDisallowedHosts(Set<String> hosts, String... hostCandidates) {
91-
for (String hostCandidate : hostCandidates) {
92-
try {
93-
hosts.add(normalizeHostForComparison(hostCandidate));
94-
} catch (UnknownHostException e) {
95-
throw new IllegalStateException("Invalid disallowed host configured: " + hostCandidate, e);
96-
}
97-
}
98-
}
99-
10072
public static WebClient create() {
10173
return builder().build();
10274
}
@@ -199,58 +171,6 @@ protected AddressResolver<InetSocketAddress> newResolver(EventExecutor executor)
199171
}
200172
}
201173

202-
/**
203-
* Resolves a hostname and validates that none of its addresses are disallowed for
204-
* outbound connections from non-HTTP paths (e.g. SMTP via JavaMail). Checks against
205-
* the cloud-metadata denylist, loopback, link-local, any-local, multicast, and IPv6
206-
* Unique Local Addresses (fc00::/7). Returns the first validated resolved address so
207-
* callers can connect to it directly, preventing DNS-rebinding TOCTOU bypasses.
208-
*
209-
* <p>RFC 1918 site-local ranges (10/8, 172.16/12, 192.168/16) are intentionally
210-
* allowed because legitimate SMTP servers frequently reside on private networks.
211-
*
212-
* @return the resolved {@link InetAddress} if the host is allowed, or empty if blocked
213-
*/
214-
public static Optional<InetAddress> resolveIfAllowed(String host) {
215-
if (!StringUtils.hasText(host)) {
216-
return Optional.empty();
217-
}
218-
219-
final String canonicalHost = normalizeHostForComparisonQuietly(host);
220-
221-
if (DISALLOWED_HOSTS.contains(canonicalHost)) {
222-
return Optional.empty();
223-
}
224-
225-
final InetAddress[] resolved;
226-
try {
227-
resolved = InetAddress.getAllByName(host);
228-
} catch (UnknownHostException e) {
229-
return Optional.empty();
230-
}
231-
232-
for (InetAddress addr : resolved) {
233-
if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(addr.getHostAddress()))
234-
|| matchesBlockedAddressClass(addr)) {
235-
return Optional.empty();
236-
}
237-
}
238-
239-
return Optional.of(resolved[0]);
240-
}
241-
242-
public static boolean isDisallowedAndFail(String host, Promise<?> promise) {
243-
final String canonicalHost = normalizeHostForComparisonQuietly(host);
244-
if (DISALLOWED_HOSTS.contains(canonicalHost) || isBlockedAddressClassInDocker(canonicalHost)) {
245-
log.warn("Host {} is disallowed. Failing the request.", host);
246-
if (promise != null) {
247-
promise.setFailure(new UnknownHostException(HOST_NOT_ALLOWED));
248-
}
249-
return true;
250-
}
251-
return false;
252-
}
253-
254174
private static Mono<ClientRequest> requestFilterFn(ClientRequest request) {
255175
final String host = request.url().getHost();
256176

@@ -259,119 +179,11 @@ private static Mono<ClientRequest> requestFilterFn(ClientRequest request) {
259179
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, "Requested url host is null or empty"));
260180
}
261181

262-
final String canonicalHost;
263-
try {
264-
canonicalHost = normalizeHostForComparison(host);
265-
} catch (UnknownHostException e) {
266-
// This exception is thrown, if the given host couldn't be resolved to an IP address. But, since we only
267-
// canonicalize after ensuring that `host` is a valid IP address, this exception should never occur.
268-
return Mono.error(new AppsmithPluginException(
269-
AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, "IP Address resolution is invalid"));
270-
}
271-
272-
return (DISALLOWED_HOSTS.contains(canonicalHost) || isBlockedAddressClassInDocker(canonicalHost))
273-
? Mono.error(new UnknownHostException(HOST_NOT_ALLOWED))
182+
return RestrictedHostFilter.isLiteralBlocked(host)
183+
? Mono.error(new UnknownHostException(RestrictedHostFilter.HOST_NOT_ALLOWED))
274184
: Mono.just(request);
275185
}
276186

277-
static boolean isBlockedIpAddressClass(String canonicalHost) {
278-
if (!isValidIpAddress(canonicalHost)) {
279-
return false;
280-
}
281-
try {
282-
return matchesBlockedAddressClass(InetAddress.getByName(canonicalHost));
283-
} catch (UnknownHostException e) {
284-
return false;
285-
}
286-
}
287-
288-
private static boolean matchesBlockedAddressClass(InetAddress address) {
289-
if (address.isLoopbackAddress()
290-
|| address.isAnyLocalAddress()
291-
|| address.isLinkLocalAddress()
292-
|| address.isMulticastAddress()) {
293-
return true;
294-
}
295-
if (address instanceof Inet6Address) {
296-
// fc00::/7 — IPv6 Unique Local Addresses
297-
byte firstByte = address.getAddress()[0];
298-
return (firstByte & (byte) 0xFE) == (byte) 0xFC;
299-
}
300-
return false;
301-
}
302-
303-
private static boolean isBlockedAddressClassInDocker(String canonicalHost) {
304-
return "1".equals(System.getenv("IN_DOCKER")) && isBlockedIpAddressClass(canonicalHost);
305-
}
306-
307-
private static boolean isValidIpAddress(String host) {
308-
if (!StringUtils.hasText(host)) {
309-
return false;
310-
}
311-
host = stripHostDecorators(host);
312-
return inetAddressValidator.isValid(host);
313-
}
314-
315-
private static String normalizeHostForComparison(String host) throws UnknownHostException {
316-
if (!StringUtils.hasText(host)) {
317-
return host;
318-
}
319-
320-
final String normalizedHost = stripHostDecorators(host.trim().toLowerCase(Locale.ROOT));
321-
return isValidIpAddress(normalizedHost) ? normalizeIpAddress(normalizedHost) : normalizedHost;
322-
}
323-
324-
private static String normalizeHostForComparisonQuietly(String host) {
325-
try {
326-
return normalizeHostForComparison(host);
327-
} catch (UnknownHostException e) {
328-
return StringUtils.hasText(host) ? stripHostDecorators(host.trim().toLowerCase(Locale.ROOT)) : host;
329-
}
330-
}
331-
332-
private static String stripHostDecorators(String host) {
333-
String sanitizedHost = host;
334-
while (sanitizedHost.endsWith(".")) {
335-
sanitizedHost = sanitizedHost.substring(0, sanitizedHost.length() - 1);
336-
}
337-
if (sanitizedHost.startsWith("[") && sanitizedHost.endsWith("]")) {
338-
sanitizedHost = sanitizedHost.substring(1, sanitizedHost.length() - 1);
339-
}
340-
return sanitizedHost;
341-
}
342-
343-
private static String normalizeIpAddress(String host) throws UnknownHostException {
344-
final InetAddress address = InetAddress.getByName(host);
345-
346-
if (address instanceof Inet6Address) {
347-
final byte[] addressBytes = address.getAddress();
348-
// Normalize IPv4-compatible and IPv4-mapped IPv6 literals back to the embedded IPv4 address so a single
349-
// denylist entry blocks equivalent literal representations such as `100.100.100.200` and
350-
// `[::100.100.100.200]`.
351-
if (isIpv4CompatibleOrMapped(addressBytes)) {
352-
return InetAddress.getByAddress(Arrays.copyOfRange(addressBytes, 12, 16))
353-
.getHostAddress();
354-
}
355-
}
356-
357-
return address.getHostAddress();
358-
}
359-
360-
private static boolean isIpv4CompatibleOrMapped(byte[] addressBytes) {
361-
if (addressBytes.length != 16) {
362-
return false;
363-
}
364-
365-
for (int i = 0; i < 10; i++) {
366-
if (addressBytes[i] != 0) {
367-
return false;
368-
}
369-
}
370-
371-
return (addressBytes[10] == 0 && addressBytes[11] == 0)
372-
|| (addressBytes[10] == (byte) 0xff && addressBytes[11] == (byte) 0xff);
373-
}
374-
375187
private static class NameResolver extends InetNameResolver {
376188

377189
public NameResolver(EventExecutor executor) {
@@ -380,7 +192,7 @@ public NameResolver(EventExecutor executor) {
380192

381193
@Override
382194
protected void doResolve(String inetHost, Promise<InetAddress> promise) {
383-
if (isDisallowedAndFail(inetHost, promise)) {
195+
if (RestrictedHostFilter.isDisallowedAndFail(inetHost, promise)) {
384196
return;
385197
}
386198

@@ -392,7 +204,7 @@ protected void doResolve(String inetHost, Promise<InetAddress> promise) {
392204
return;
393205
}
394206

395-
if (isDisallowedAndFail(address.getHostAddress(), promise)) {
207+
if (RestrictedHostFilter.isDisallowedAndFail(address.getHostAddress(), promise)) {
396208
return;
397209
}
398210

@@ -401,7 +213,7 @@ protected void doResolve(String inetHost, Promise<InetAddress> promise) {
401213

402214
@Override
403215
protected void doResolveAll(String inetHost, Promise<List<InetAddress>> promise) {
404-
if (isDisallowedAndFail(inetHost, promise)) {
216+
if (RestrictedHostFilter.isDisallowedAndFail(inetHost, promise)) {
405217
return;
406218
}
407219

@@ -415,7 +227,7 @@ protected void doResolveAll(String inetHost, Promise<List<InetAddress>> promise)
415227

416228
// Even if _one_ of the addresses is disallowed, we fail the request.
417229
for (InetAddress address : addresses) {
418-
if (isDisallowedAndFail(address.getHostAddress(), promise)) {
230+
if (RestrictedHostFilter.isDisallowedAndFail(address.getHostAddress(), promise)) {
419231
return;
420232
}
421233
}

app/server/appsmith-interfaces/src/test/java/com/appsmith/external/connections/OAuth2ClientCredentialsTest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ public class OAuth2ClientCredentialsTest {
3939
public static void setUp() throws IOException {
4040
mockEndpoint = new MockWebServer();
4141
mockEndpoint.start();
42+
// The SSRF filter is JVM-wide disabled for all surefire tests (see root pom), so this
43+
// MockWebServer-on-loopback test works without further setup. The filter is exercised
44+
// separately in RestrictedHostFilterTest / WebClientUtilsTest.
4245
}
4346

4447
@AfterAll

0 commit comments

Comments
 (0)