Skip to content

Commit d847cf4

Browse files
authored
Merge pull request #41961 from appsmithorg/release
chore(release): promote release to master
2 parents 26fe7c1 + 48ac053 commit d847cf4

34 files changed

Lines changed: 2739 additions & 658 deletions

File tree

.github/workflows/helm-unittest.yml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@ jobs:
2121
- name: Checkout the code
2222
uses: actions/checkout@v4
2323

24-
# Subchart .tgz files are gitignored, so a fresh checkout has no charts/.
25-
# Pull dependencies first so tests that render subchart templates (e.g.
26-
# deriving the redis master host from the redis subchart fullname) pass.
24+
- name: Setup Helm
25+
uses: azure/setup-helm@v4
26+
with:
27+
version: v4.1.4
28+
29+
# charts/ is gitignored, so pull subcharts before running the tests.
30+
# Done here, not in the container, which can't write to the mounted dir.
2731
- name: Build chart dependencies
28-
run: |
29-
docker run --rm -v $(pwd):/apps --entrypoint helm helmunittest/helm-unittest dependency update .
32+
run: helm dependency build
3033

3134
- name: Unittest
3235
run: |

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