Skip to content

fix(security): block SSRF via send-test-email SMTP host validation (GHSA-vvxf-f8q9-86gh)#41666

Open
subrata71 wants to merge 2 commits intoreleasefrom
fix/ssrf-send-test-email-javamail-bypass
Open

fix(security): block SSRF via send-test-email SMTP host validation (GHSA-vvxf-f8q9-86gh)#41666
subrata71 wants to merge 2 commits intoreleasefrom
fix/ssrf-send-test-email-javamail-bypass

Conversation

@subrata71
Copy link
Copy Markdown
Collaborator

@subrata71 subrata71 commented Mar 27, 2026

Description

TL;DR: The POST /api/v1/admin/send-test-email endpoint was vulnerable to SSRF (CWE-918) and error-based information disclosure (CWE-209). Attacker-controlled smtpHost/smtpPort were passed directly to JavaMail, bypassing the WebClientUtils.IP_CHECK_FILTER that only protects HTTP requests. This fix adds host validation and sanitizes error messages.

Root Cause

EnvManagerCEImpl.sendTestEmail() accepted user-controlled SMTP host/port and established raw TCP connections via JavaMailSenderImpl without any IP validation. The existing WebClientUtils.IP_CHECK_FILTER only applies to Spring WebClient HTTP requests — a completely separate code path from JavaMail SMTP.

Changes

  1. WebClientUtils.validateHostNotDisallowed() — new reusable method that checks a hostname against the existing cloud-metadata denylist, resolves it via DNS, and rejects loopback, link-local, site-local, any-local, and multicast addresses.
  2. EnvManagerCEImpl.sendTestEmail() — calls the new validation before connecting. Error messages from MessagingException/MailException are no longer returned verbatim to the caller, preventing error-based port scanning.
  3. Tests — added parameterized tests in WebClientUtilsTest for blocked hosts (private ranges, metadata IPs, localhost, unresolvable) and allowed hosts (legitimate SMTP servers). Added SSRF regression tests in EnvManagerTest.

Fixes https://linear.app/appsmith/issue/APP-15034/ssrf-via-post-apiv1adminsend-test-email-javamail-bypasses-webclient-ip
Advisory: GHSA-vvxf-f8q9-86gh

Automation

/ok-to-test tags="@tag.All"

🔍 Cypress test results

Tip

🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
Workflow run: https://github.com/appsmithorg/appsmith/actions/runs/23677660193
Commit: 524cc1b
Cypress dashboard.
Tags: @tag.All
Spec:


Sat, 28 Mar 2026 08:17:19 UTC

Communication

Should the DevRel and Marketing teams inform users about this change?

  • Yes
  • No

Summary by CodeRabbit

  • New Features

    • Added SMTP host validation that rejects disallowed, private, loopback and unresolvable hosts before sending test emails.
  • Bug Fixes

    • Fail-fast handling for invalid SMTP hosts and unified, non-sensitive error messaging for SMTP connection/send failures.
  • Tests

    • Expanded tests covering blocked hosts, allowed public SMTP hosts, null/empty input, and error behavior for sendTestEmail.

The sendTestEmail endpoint accepted attacker-controlled smtpHost/smtpPort
and passed them directly to JavaMailSenderImpl, bypassing the WebClient
IP_CHECK_FILTER. This allowed admin users to reach internal services,
cloud metadata endpoints, and private networks via raw SMTP connections.

- Add WebClientUtils.validateHostNotDisallowed() that checks hostnames
  against the existing denylist and, after DNS resolution, rejects
  loopback, link-local, site-local, and multicast addresses
- Apply host validation in sendTestEmail before connecting
- Sanitize error messages to prevent information leakage via error-based
  port scanning (CWE-209)
- Add tests for blocked and allowed hosts

Fixes https://linear.app/appsmith/issue/APP-15034
Advisory: GHSA-vvxf-f8q9-86gh
@linear
Copy link
Copy Markdown

linear bot commented Mar 27, 2026

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 27, 2026

Walkthrough

Adds a new public API resolveIfAllowed(String host) in WebClientUtils that performs outbound host allow/deny checks via DNS resolution and address-level checks. sendTestEmail(...) now uses this resolver to fail fast for disallowed SMTP hosts and returns a generic SMTP error on connection/send failures. Tests were added/updated for the new behavior.

Changes

Cohort / File(s) Summary
Host resolver
app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java
Added public static Optional<InetAddress> resolveIfAllowed(String host) which canonicalizes input, checks DISALLOWED_HOSTS, resolves via InetAddress.getAllByName, rejects IPv6 ULA (fc00::/7), loopback, link-local, any-local and multicast addresses, and returns the first allowed InetAddress or empty.
Resolver unit tests
app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java
Added parameterized and direct tests asserting blocked behavior for null/empty/whitespace, loopback/local/metadata addresses, unresolvable hosts, and allowed behaviour for selected SMTP hostnames; verifies resolved address format for allowed host.
SMTP integration change
app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java
sendTestEmail(...) now calls WebClientUtils.resolveIfAllowed(...) and fails with GENERIC_BAD_REQUEST + "Invalid SMTP host." when empty; uses resolved address for JavaMailSenderImpl host; logs connection/send exceptions and returns a constant SMTP_GENERIC_ERROR instead of raw exception messages.
EnvManager tests
app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java
Added helpers mockSuperUser() and buildDto(String smtpHost) and a parameterized test asserting sendTestEmail(...) throws AppsmithException with "Invalid SMTP host" for blocked hosts (e.g., 127.0.0.1, 169.254.169.254, localhost).

Sequence Diagram

sequenceDiagram
    participant Client
    participant EnvManager as EnvManager<br/>(sendTestEmail)
    participant WebClientUtils as WebClientUtils<br/>(resolveIfAllowed)
    participant DNS as DNS/InetAddress
    participant SMTP as SMTP Server

    Client->>EnvManager: sendTestEmail(smtpHost)
    EnvManager->>WebClientUtils: resolveIfAllowed(smtpHost)
    WebClientUtils->>WebClientUtils: canonicalize & check DISALLOWED_HOSTS
    alt Host is in disallowed list
        WebClientUtils-->>EnvManager: Optional.empty()
    else Host not pre-disallowed
        WebClientUtils->>DNS: InetAddress.getAllByName(host)
        DNS-->>WebClientUtils: resolved addresses[]
        WebClientUtils->>WebClientUtils: check each address (ULA, loopback, link-local, any-local, multicast)
        alt any address rejected
            WebClientUtils-->>EnvManager: Optional.empty()
        else all addresses allowed
            WebClientUtils-->>EnvManager: Optional.of(resolved[0])
        end
    end

    alt Validation failed
        EnvManager-->>Client: AppsmithException("Invalid SMTP host.")
    else Validation passed
        EnvManager->>SMTP: connect/send using resolved host
        SMTP-->>EnvManager: success or exception
        alt exception
            EnvManager-->>Client: GENERIC_BAD_REQUEST (SMTP_GENERIC_ERROR)
        else
            EnvManager-->>Client: success
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

A resolver peers through DNS mist and fog,
Denies the loops, the locals, the bog,
It hands EnvManager a single allowed IP,
SMTP now guarded — quick fail, then a try,
Safe sends ahead, no metadata sly.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main security fix: blocking SSRF via SMTP host validation with reference to the advisory identifier.
Description check ✅ Passed PR description follows the template structure with comprehensive sections covering motivation, root cause, changes, tests, and issue references.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ssrf-send-test-email-javamail-bypass

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@subrata71 subrata71 self-assigned this Mar 27, 2026
@subrata71 subrata71 added the ok-to-test Required label for CI label Mar 27, 2026
@subrata71 subrata71 changed the title fix(security): block SSRF via send-test-email SMTP host validation fix(security): block SSRF via send-test-email SMTP host validation (GHSA-vvxf-f8q9-86gh) Mar 27, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java (1)

79-84: Avoid live DNS dependency in this unit test.

Line 82 performs real DNS resolution for external domains, which can make the test flaky in restricted CI environments. Prefer a deterministic resolver abstraction (mocked in tests) or move live-resolution checks to an integration-test suite.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java`
around lines 79 - 84, The test
validateHostNotDisallowed_allowsLegitimateSmtpHosts calls
WebClientUtils.validateHostNotDisallowed which triggers real DNS resolution
making the unit test flaky; modify the code under test to accept a DNS/resolver
abstraction (or provide an overloaded method) and update this test to inject a
deterministic mocked resolver that returns expected answers, or alternatively
replace external hosts with deterministic local test hosts (e.g., localhost or a
controlled test domain) and assert against those; locate the
WebClientUtils.validateHostNotDisallowed method and the test
validateHostNotDisallowed_allowsLegitimateSmtpHosts to implement the resolver
injection and update the test to mock/stub resolution so no live DNS lookup
occurs.
app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java (1)

404-450: Consider parameterizing the blocked-host tests.

Line 404-Line 450 repeats the same pattern 4 times. A single @ParameterizedTest with host values would keep this easier to extend.

♻️ Refactor sketch
+ `@ParameterizedTest`
+ `@ValueSource`(strings = {"127.0.0.1", "169.254.169.254", "10.0.0.1", "localhost"})
+ public void sendTestEmail_WhenBlockedHost_ThrowsException(String host) {
+     mockSuperUser();
+     StepVerifier.create(envManager.sendTestEmail(buildDto(host)))
+             .expectErrorSatisfies(e -> {
+                 assertThat(e).isInstanceOf(AppsmithException.class);
+                 assertThat(e.getMessage()).contains("Invalid SMTP host");
+             })
+             .verify();
+ }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java`
around lines 404 - 450, Replace the four near-duplicate tests in EnvManagerTest
with a single parameterized test: create a new `@ParameterizedTest` method (e.g.,
sendTestEmail_WhenBlockedHost_ThrowsException) that calls mockSuperUser(), then
uses StepVerifier.create(envManager.sendTestEmail(buildDto(host))) and the same
expectErrorSatisfies assertions; supply the blocked host values ("127.0.0.1",
"169.254.169.254", "10.0.0.1", "localhost") via `@ValueSource` or `@CsvSource` so
the logic in sendTestEmail and buildDto is exercised once for each host. Ensure
imports for the JUnit Jupiter parameterized annotations are added and remove the
four original test methods.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java`:
- Around line 231-236: The validation in WebClientUtils currently relies on
InetAddress methods which miss IPv6 Unique Local Addresses (fc00::/7); update
the block in the method that checks addr (the addr variable and HOST_NOT_ALLOWED
return) to explicitly detect IPv6 ULA by: if addr is an instance of
java.net.Inet6Address, call addr.getAddress() and test the first byte with
((bytes[0] & 0xFE) == 0xFC) (this matches fc00::/7), and if true return
Optional.of(HOST_NOT_ALLOWED); keep the existing
loopback/link-local/site-local/any-local/multicast checks intact.

In
`@app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java`:
- Around line 793-801: The current validation via
WebClientUtils.validateHostNotDisallowed(requestDTO.getSmtpHost()) can be
bypassed by DNS rebinding because JavaMailSenderImpl (mailSender) reconnects
using the original hostname; fix by performing a single trusted DNS resolution
during validation and using that resolved InetAddress for the actual socket
connect (or supply a custom SocketFactory/SSLSocketFactory to JavaMailSenderImpl
that enforces/connects to the validated InetAddress) so the mail connection
cannot re-resolve to a different IP at connect time; update EnvManagerCEImpl to
use the resolved address returned from validateHostNotDisallowed (or a new
resolver helper) when configuring mailSender and ensure the SMTP EHLO/Host
header is set appropriately if SMTP requires the original hostname.

---

Nitpick comments:
In
`@app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java`:
- Around line 79-84: The test
validateHostNotDisallowed_allowsLegitimateSmtpHosts calls
WebClientUtils.validateHostNotDisallowed which triggers real DNS resolution
making the unit test flaky; modify the code under test to accept a DNS/resolver
abstraction (or provide an overloaded method) and update this test to inject a
deterministic mocked resolver that returns expected answers, or alternatively
replace external hosts with deterministic local test hosts (e.g., localhost or a
controlled test domain) and assert against those; locate the
WebClientUtils.validateHostNotDisallowed method and the test
validateHostNotDisallowed_allowsLegitimateSmtpHosts to implement the resolver
injection and update the test to mock/stub resolution so no live DNS lookup
occurs.

In
`@app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java`:
- Around line 404-450: Replace the four near-duplicate tests in EnvManagerTest
with a single parameterized test: create a new `@ParameterizedTest` method (e.g.,
sendTestEmail_WhenBlockedHost_ThrowsException) that calls mockSuperUser(), then
uses StepVerifier.create(envManager.sendTestEmail(buildDto(host))) and the same
expectErrorSatisfies assertions; supply the blocked host values ("127.0.0.1",
"169.254.169.254", "10.0.0.1", "localhost") via `@ValueSource` or `@CsvSource` so
the logic in sendTestEmail and buildDto is exercised once for each host. Ensure
imports for the JUnit Jupiter parameterized annotations are added and remove the
four original test methods.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3abcb242-ae73-41d6-9588-ab5160e4e72a

📥 Commits

Reviewing files that changed from the base of the PR and between b47d782 and ce761a5.

📒 Files selected for processing (4)
  • app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java
  • app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java
  • app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java
  • app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java

Address CodeRabbitAI review findings and Cypress test regression:

1. DNS rebinding TOCTOU (Critical): Rename validateHostNotDisallowed to
   resolveIfAllowed, returning Optional<InetAddress>. The caller now uses
   the resolved IP directly for mailSender.setHost(), eliminating the
   window between validation and connection where DNS could rebind.

2. IPv6 ULA gap (Major): Java's isSiteLocalAddress() only checks the
   deprecated fec0::/10 prefix, not fc00::/7 (Unique Local Addresses).
   Added explicit byte-level check: (firstByte & 0xFE) == 0xFC.

3. Cypress test failure: Removed isSiteLocalAddress() from the blocking
   criteria. The existing WebClient denylist intentionally allows RFC 1918
   (10/8, 172.16/12, 192.168/16) because legitimate SMTP servers reside
   on private networks. host.docker.internal (used in CI) resolves to a
   site-local address and was incorrectly blocked.

4. Test cleanup: Parameterized duplicate EnvManager blocked-host tests
   into a single @ParameterizedTest. Updated WebClientUtilsTest for new
   API signature and added resolved-address assertion.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java (1)

406-417: Good SSRF regression coverage; consider expanding test vectors.

The parameterized test covers the critical SSRF vectors (loopback, AWS metadata IP, localhost). The assertion correctly uses contains() which handles the trailing period in the production message.

Consider adding 0.0.0.0 (any-local), 168.63.129.16 (Azure metadata), and 100.100.100.200 (Alibaba metadata) to align with the broader coverage in WebClientUtilsTest. This strengthens the integration-level guarantee.

♻️ Suggested expanded test vectors
 `@ParameterizedTest`
-@ValueSource(strings = {"127.0.0.1", "169.254.169.254", "localhost"})
+@ValueSource(strings = {"127.0.0.1", "169.254.169.254", "localhost", "0.0.0.0", "168.63.129.16", "100.100.100.200"})
 public void sendTestEmail_WhenBlockedHost_ThrowsException(String host) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java`
around lines 406 - 417, The parameterized test
sendTestEmail_WhenBlockedHost_ThrowsException currently only checks
{"127.0.0.1", "169.254.169.254", "localhost"} and should be expanded to include
additional metadata/any-local hosts to improve SSRF coverage; update the
`@ValueSource` on the test (and keep assertions on envManager.sendTestEmail and
buildDto unchanged) to add "0.0.0.0", "168.63.129.16", and "100.100.100.200" so
the test covers Azure, Alibaba, and any-local vectors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java`:
- Around line 90-95: The test resolveIfAllowed_returnsResolvedAddress assumes
IPv4; update it so WebClientUtils.resolveIfAllowed("smtp.gmail.com") is asserted
present and the returned InetAddress (result.get().getHostAddress()) is
validated for either IPv4 or IPv6—for example keep the existing IPv4 regex OR
accept addresses containing ':' (IPv6) or use a standard InetAddress validation
method—so the assertion passes regardless of whether the resolver returns IPv4
or IPv6.

---

Nitpick comments:
In
`@app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java`:
- Around line 406-417: The parameterized test
sendTestEmail_WhenBlockedHost_ThrowsException currently only checks
{"127.0.0.1", "169.254.169.254", "localhost"} and should be expanded to include
additional metadata/any-local hosts to improve SSRF coverage; update the
`@ValueSource` on the test (and keep assertions on envManager.sendTestEmail and
buildDto unchanged) to add "0.0.0.0", "168.63.129.16", and "100.100.100.200" so
the test covers Azure, Alibaba, and any-local vectors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8a5e2d63-b5c2-42eb-9345-11184c43d151

📥 Commits

Reviewing files that changed from the base of the PR and between ce761a5 and 524cc1b.

📒 Files selected for processing (4)
  • app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java
  • app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java
  • app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java
  • app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java
  • app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java

Comment on lines +90 to +95
@Test
public void resolveIfAllowed_returnsResolvedAddress() {
Optional<InetAddress> result = WebClientUtils.resolveIfAllowed("smtp.gmail.com");
assertTrue(result.isPresent());
assertTrue(result.get().getHostAddress().matches("\\d+\\.\\d+\\.\\d+\\.\\d+"));
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

IPv4-only regex may fail for IPv6-capable resolvers.

The regex \\d+\\.\\d+\\.\\d+\\.\\d+ assumes IPv4. If smtp.gmail.com resolves to an IPv6 address (Google supports both), this assertion fails.

🛡️ Proposed fix to handle both IPv4 and IPv6
     `@Test`
     public void resolveIfAllowed_returnsResolvedAddress() {
         Optional<InetAddress> result = WebClientUtils.resolveIfAllowed("smtp.gmail.com");
         assertTrue(result.isPresent());
-        assertTrue(result.get().getHostAddress().matches("\\d+\\.\\d+\\.\\d+\\.\\d+"));
+        // Just verify we got a valid address string - could be IPv4 or IPv6
+        String hostAddress = result.get().getHostAddress();
+        assertTrue(hostAddress != null && !hostAddress.isEmpty(), "Expected non-empty host address");
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java`
around lines 90 - 95, The test resolveIfAllowed_returnsResolvedAddress assumes
IPv4; update it so WebClientUtils.resolveIfAllowed("smtp.gmail.com") is asserted
present and the returned InetAddress (result.get().getHostAddress()) is
validated for either IPv4 or IPv6—for example keep the existing IPv4 regex OR
accept addresses containing ':' (IPv6) or use a standard InetAddress validation
method—so the assertion passes regardless of whether the resolver returns IPv4
or IPv6.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ok-to-test Required label for CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant