CVE-2026-7299
Description
Appsmith SQL editor's autocomplete allows persistent XSS via malicious database object names, impacting other workspace members.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Appsmith SQL editor's autocomplete allows persistent XSS via malicious database object names, impacting other workspace members.
Vulnerability
Appsmith's SQL query editor autocomplete functionality fails to sanitize database object names before rendering them using innerHTML. This allows an authenticated Developer to inject persistent Cross-Site Scripting (XSS) payloads through malicious table or column names. The vulnerability is present in versions prior to 2.1 and specifically affects versions like 1.98 [2, 4]. The issue lies in the custom CodeMirror render callback which overrides the default safe textContent rendering with innerHTML without sanitization [2].
Exploitation
An attacker with developer-level access to a shared datasource can create malicious database objects (tables or columns) with names containing XSS payloads. When any other workspace member interacts with the same datasource and triggers the SQL autocomplete functionality, the stored payload is executed in their browser session [2, 4]. This requires an account with developer access and can be triggered by typing in the SQL editor, for example, SELECT * FROM [2, 4].
Impact
Successful exploitation allows for arbitrary JavaScript execution in the browser of other workspace members who trigger SQL autocomplete. This can lead to session hijacking, privilege escalation, or credential theft within the affected workspace [2, 4]. The scope of the compromise is limited to the sessions of other workspace members interacting with the same datasource [2].
Mitigation
Version 2.1 of Appsmith fixes CVE-2026-7299 [4]. The fix involves preventing stored XSS via SQL autocomplete [1]. No other workarounds or end-of-life status information is available in the provided references.
- fix: prevent stored XSS via SQL autocomplete (GHSA-vjfq-fvfc-3vjw) (#… · appsmithorg/appsmith@99d6918
- GitHub - Stuub/Appsmith-1.98-Stored-XSS-Exploit: Automating the exploitation of CVE-2026-7299 - Stored XSS via Database Table/Column Names in SQL Autocomplete within Appsmith =>1.99. Initial discovery 30/03/26
- CERT/CC Vulnerability Note VU#265691
AI Insight generated on Jun 2, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
299d691809199fix: prevent stored XSS via SQL autocomplete (GHSA-vjfq-fvfc-3vjw) (#41760)
7 files changed · +345 −30
app/client/src/components/editorComponents/CodeEditor/hintHelpers.test.ts+70 −0 modified@@ -181,4 +181,74 @@ describe("hint helpers", () => { ); }); }); + + describe("XSS regression (GHSA-vjfq-fvfc-3vjw)", () => { + // A workspace Developer who can run DDL on a shared datasource used to be + // able to inject arbitrary JavaScript into another member's browser by + // creating a table whose name is an HTML payload. The SQL hint renderer + // wrote the raw identifier to `innerHTML`. These tests lock the renderer + // to `textContent` so the payload can never be parsed as markup again. + + const HTML_PAYLOAD = '<img src=x onerror="window.__xssFired=true">'; + const SVG_PAYLOAD = '<svg onload="window.__xssFired=true"></svg>'; + + beforeEach(() => { + // @ts-expect-error test probe + delete window.__xssFired; + }); + + function runRenderer(text: string, className: string) { + const hinter = new SqlHintHelper(); + + hinter.setDatasourceTableKeys({ [text]: "table" }); + + const completions = { + from: { line: 0, ch: 0 }, + to: { line: 0, ch: 0 }, + list: [{ text, className, displayText: text }], + } as unknown as Parameters< + SqlHintHelper["addCustomAttributesToCompletions"] + >[0]; + + const rendered = hinter.addCustomAttributesToCompletions(completions); + const li = document.createElement("li") as HTMLLIElement; + const completion = rendered.list[0] as unknown as { + render: ( + el: HTMLLIElement, + data: unknown, + cur: { text: string; className: string }, + ) => void; + }; + + completion.render(li, undefined, { text, className }); + + return li; + } + + it("renders a malicious table name as text, not HTML", () => { + const li = runRenderer(HTML_PAYLOAD, "CodeMirror-hint-table"); + + expect(li.querySelector("img")).toBeNull(); + expect(li.textContent).toBe(HTML_PAYLOAD); + // @ts-expect-error test probe + expect(window.__xssFired).toBeUndefined(); + }); + + it("renders a malicious SVG table name without creating an SVG element", () => { + const li = runRenderer(SVG_PAYLOAD, "CodeMirror-hint-table"); + + expect(li.querySelector("svg")).toBeNull(); + expect(li.textContent).toBe(SVG_PAYLOAD); + // @ts-expect-error test probe + expect(window.__xssFired).toBeUndefined(); + }); + + it("renders a malicious table.column composite key as text", () => { + const composite = `public.${HTML_PAYLOAD}`; + const li = runRenderer(composite, "CodeMirror-hint-table"); + + expect(li.querySelector("img")).toBeNull(); + expect(li.textContent).toBe(composite); + }); + }); });
app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts+4 −1 modified@@ -162,7 +162,10 @@ export class SqlHintHelper { LiElement.setAttribute("icontext", iconText); LiElement.classList.add("cm-sql-hint"); LiElement.classList.add(`cm-sql-hint-${iconBgType}`); - LiElement.innerHTML = text; + // Using textContent (not innerHTML) to prevent stored XSS — database + // table/column names can legitimately contain any character, so we + // must render them as text. See GHSA-vjfq-fvfc-3vjw. + LiElement.textContent = text; }; return completion;
app/client/src/utils/autocomplete/CodemirrorTernService.ts+3 −3 modified@@ -13,6 +13,7 @@ import { } from "components/editorComponents/CodeEditor/EditorConfig"; import type { EntityTypeValue } from "ee/entities/DataTree/types"; import { AutocompleteSorter } from "./AutocompleteSortRules"; +import { renderKeywordHint } from "./keywordHintRenderer"; import { getCompletionsForKeyword } from "./keywordCompletion"; import TernWorkerServer from "./TernWorkerService"; import { AutocompleteDataType } from "./AutocompleteDataType"; @@ -605,13 +606,12 @@ class CodeMirrorTernService { element: HTMLElement, // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any - self: any, + _self: any, // TODO: Fix this the next time the file is edited // eslint-disable-next-line @typescript-eslint/no-explicit-any data: any, ) => { - element.setAttribute("keyword", data.displayText); - element.innerHTML = data.displayText; + renderKeywordHint(element, data.displayText); }; const trimmedFocusedValueLength = lineValue
app/client/src/utils/autocomplete/keywordCompletion.test.ts+120 −0 added@@ -0,0 +1,120 @@ +import { getCompletionsForKeyword } from "./keywordCompletion"; +import type { Completion, TernCompletionResult } from "./CodemirrorTernService"; + +// Regression tests for GHSA-vjfq-fvfc-3vjw defense-in-depth. +// +// `getCompletionsForKeyword` produces CodeMirror completion objects +// whose `render` closures used to write their label to `element.innerHTML`. +// Label strings are either hardcoded literals or `completion.text`, and +// `completion.text` is gated by an outer `switch` that only matches a +// fixed set of JS keywords, so there is no current exploit path. The +// sink pattern, however, is the one GHSA-vjfq-fvfc-3vjw exploited in +// the SQL hint renderer. These tests lock every keyword variant to +// text-only rendering so the pattern cannot come back. + +function stubCompletion(text: string): Completion<TernCompletionResult> { + return { + text, + displayText: text, + className: "CodeMirror-hint-keyword", + data: {} as unknown as TernCompletionResult, + origin: "test", + render: undefined, + type: "keyword", + } as unknown as Completion<TernCompletionResult>; +} + +const KEYWORDS = [ + "for", + "while", + "do", + "if", + "switch", + "function", + "try", + "throw", + "new", + "async", +]; + +describe("getCompletionsForKeyword (GHSA-vjfq-fvfc-3vjw defense-in-depth)", () => { + it.each(KEYWORDS)( + "every render closure for '%s' produces text-only output", + (keyword) => { + const completions = getCompletionsForKeyword(stubCompletion(keyword), 0); + + expect(completions.length).toBeGreaterThan(0); + + for (const completion of completions) { + const element = document.createElement("li"); + + // The production render closures take a single `HTMLElement` + // argument; CodeMirror's typed signature accepts more, so we + // call through an `unknown` cast to match the real runtime shape. + (completion.render as unknown as (el: HTMLElement) => void)?.(element); + + expect(element.children.length).toBe(0); + expect(element.textContent ?? "").not.toBe(""); + expect(element.getAttribute("keyword")).not.toBeNull(); + } + }, + ); + + // The CSS `content: attr(keyword)` rule renders the descriptive label + // as a suffix next to the hint. This regression test pins that the + // refactored renderer continues to set the same per-keyword label the + // inline renderers used before the GHSA-vjfq-fvfc-3vjw hardening, so + // users keep seeing e.g. "For Loop" next to a `for` hint. + it.each<[string, string, string]>([ + ["for", "for-loop", "For Loop"], + ["while", "while-loop", "While Statement"], + ["do", "do-while-statement", "do-While Statement"], + ["if", "if-statement", "if Statement"], + ["switch", "switch-statement", "Switch Statement"], + ["function", "function-statement", "Function Statement"], + ["try", "try-catch", "Try-catch Statement"], + ["throw", "throw-exception", "Throw Exception"], + ["new", "new-statement", "new Statement"], + ])( + "preserves the descriptive label for keyword '%s' / snippet '%s'", + (keyword, snippetName, expectedDescription) => { + const completions = getCompletionsForKeyword(stubCompletion(keyword), 0); + const snippet = completions.find((c) => c.name === snippetName); + + expect(snippet).toBeDefined(); + + const element = document.createElement("li"); + + (snippet?.render as unknown as (el: HTMLElement) => void)?.(element); + + expect(element.getAttribute("keyword")).toBe(expectedDescription); + }, + ); + + it("does not parse an HTML payload when a spoofed completion is rendered", () => { + // Build a completion whose outer `text` is the payload, then pass it + // through `getCompletionsForKeyword` with `keywordName` forced to a + // valid JS keyword so the switch matches. The inner renderers close + // over the outer `completion`, so this simulates the worst-case + // future path where `completion.text` reaches the sink. + const payload = '<img src=x onerror="window.__xssFired=true">'; + const forgedOuterCompletion = { + ...stubCompletion(payload), + text: "for", + } as unknown as Completion<TernCompletionResult>; + + // Patch `text` on the stub so `keywordName = completion.text` matches + // "for" in the switch, but the closures will later resolve the + // original payload if any implementation regressed. This keeps the + // test honest without relying on internal closure behaviour. + forgedOuterCompletion.text = "for"; + + const completions = getCompletionsForKeyword(forgedOuterCompletion, 0); + const element = document.createElement("li"); + + (completions[0]?.render as unknown as (el: HTMLElement) => void)?.(element); + + expect(element.querySelector("img")).toBeNull(); + expect(element.children.length).toBe(0); + }); +});
app/client/src/utils/autocomplete/keywordCompletion.ts+22 −26 modified@@ -1,4 +1,5 @@ import type { Completion, TernCompletionResult } from "./CodemirrorTernService"; +import { renderKeywordHint } from "./keywordHintRenderer"; export const getCompletionsForKeyword = ( completion: Completion<TernCompletionResult>, @@ -19,26 +20,23 @@ export const getCompletionsForKeyword = ( name: "for-loop", text: `for(let i=0;i < array.length;i++){\n${indentationSpace}\tconst element = array[i];\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "For Loop"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "For Loop"); }, }); completions.push({ ...completion, name: "for-in-loop", text: `for(const key in object) {\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "For-in Loop"); - element.innerHTML = "forin"; + renderKeywordHint(element, "forin", "For-in Loop"); }, }); completions.push({ ...completion, name: "for-of-loop", text: `for(const iterator of object){\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "For-of Loop"); - element.innerHTML = "forof"; + renderKeywordHint(element, "forof", "For-of Loop"); }, }); break; @@ -49,8 +47,7 @@ export const getCompletionsForKeyword = ( name: "while-loop", text: `while(condition){\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "While Statement"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "While Statement"); }, }); break; @@ -61,8 +58,7 @@ export const getCompletionsForKeyword = ( name: "do-while-statement", text: `do{\n\n${indentationSpace}} while (condition);`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "do-While Statement"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "do-While Statement"); }, }); break; @@ -74,8 +70,7 @@ export const getCompletionsForKeyword = ( name: "if-statement", text: `if(condition){\n\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "if Statement"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "if Statement"); }, }); @@ -86,8 +81,7 @@ export const getCompletionsForKeyword = ( name: "switch-statement", text: `switch(key){\n${indentationSpace}\tcase value:\n${indentationSpace}\t\tbreak;\n${indentationSpace}\tdefault:\n${indentationSpace}\t\tbreak;\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "Switch Statement"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "Switch Statement"); }, }); @@ -98,8 +92,7 @@ export const getCompletionsForKeyword = ( name: "function-statement", text: `function name(params){\n\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "Function Statement"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "Function Statement"); }, }); @@ -110,8 +103,7 @@ export const getCompletionsForKeyword = ( name: "try-catch", text: `try{\n\n${indentationSpace}}catch(error){\n\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "Try-catch Statement"); - element.innerHTML = "try-catch"; + renderKeywordHint(element, "try-catch", "Try-catch Statement"); }, }); break; @@ -122,8 +114,7 @@ export const getCompletionsForKeyword = ( name: "throw-exception", text: `throw new Error("");`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "Throw Exception"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "Throw Exception"); }, }); break; @@ -133,8 +124,7 @@ export const getCompletionsForKeyword = ( name: "new-statement", text: `const name = new type(arguments);`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "new Statement"); - element.innerHTML = completion.text; + renderKeywordHint(element, completion.text, "new Statement"); }, }); break; @@ -145,17 +135,23 @@ export const getCompletionsForKeyword = ( name: "async-function", text: `async function() {\n\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "async Function Statement"); - element.innerHTML = completion.text; + renderKeywordHint( + element, + completion.text, + "async Function Statement", + ); }, }, { ...completion, name: "async-arrow-function", text: `async () => {\n\n${indentationSpace}}`, render: (element: HTMLElement) => { - element.setAttribute("keyword", "async Arrow Function Statement"); - element.innerHTML = completion.text; + renderKeywordHint( + element, + completion.text, + "async Arrow Function Statement", + ); }, }, );
app/client/src/utils/autocomplete/keywordHintRenderer.test.ts+94 −0 added@@ -0,0 +1,94 @@ +import { renderKeywordHint } from "./keywordHintRenderer"; + +// Regression tests for GHSA-vjfq-fvfc-3vjw defense-in-depth. +// +// The JS keyword and tern autocomplete renderers used to write their +// label directly to `innerHTML`. While those label strings are currently +// bounded to JavaScript keyword literals via a surrounding switch, the +// sink pattern matches the one the SQL hint renderer was exploited +// through (GHSA-vjfq-fvfc-3vjw). We consolidate the renderer into a +// single safe utility that writes to `textContent` and set that label +// as the `keyword` attribute, and lock it with these tests. + +describe("renderKeywordHint (GHSA-vjfq-fvfc-3vjw defense-in-depth)", () => { + beforeEach(() => { + // @ts-expect-error test probe + delete window.__xssFired; + }); + + it("writes the label as text, not HTML", () => { + const element = document.createElement("li"); + const label = "for-loop"; + + renderKeywordHint(element, label); + + expect(element.children.length).toBe(0); + expect(element.textContent).toBe(label); + expect(element.getAttribute("keyword")).toBe(label); + }); + + it("does not parse an HTML payload into the DOM", () => { + const element = document.createElement("li"); + const payload = '<img src=x onerror="window.__xssFired=true">'; + + renderKeywordHint(element, payload); + + expect(element.querySelector("img")).toBeNull(); + expect(element.children.length).toBe(0); + expect(element.textContent).toBe(payload); + // @ts-expect-error test probe + expect(window.__xssFired).toBeUndefined(); + }); + + it("does not parse an SVG payload into the DOM", () => { + const element = document.createElement("li"); + const payload = '<svg onload="window.__xssFired=true"></svg>'; + + renderKeywordHint(element, payload); + + expect(element.querySelector("svg")).toBeNull(); + expect(element.children.length).toBe(0); + expect(element.textContent).toBe(payload); + // @ts-expect-error test probe + expect(window.__xssFired).toBeUndefined(); + }); + + it("tolerates an empty label without creating empty children", () => { + const element = document.createElement("li"); + + renderKeywordHint(element, ""); + + expect(element.children.length).toBe(0); + expect(element.textContent).toBe(""); + expect(element.getAttribute("keyword")).toBe(""); + }); + + it("uses a separate description for the keyword attribute when provided", () => { + // The CSS rule `.CodeMirror-Tern-completion-keyword[keyword]:after + // { content: attr(keyword); }` renders the `keyword` attribute as a + // human-readable suffix. Callers in keywordCompletion.ts pass a + // descriptive label there (e.g. "For Loop") that is intentionally + // different from the label rendered inside the hint (e.g. "for"). + const element = document.createElement("li"); + + renderKeywordHint(element, "for", "For Loop"); + + expect(element.textContent).toBe("for"); + expect(element.getAttribute("keyword")).toBe("For Loop"); + expect(element.children.length).toBe(0); + }); + + it("does not HTML-parse the description either", () => { + const element = document.createElement("li"); + const payload = '<img src=x onerror="window.__xssFired=true">'; + + renderKeywordHint(element, "for", payload); + + // The description is written as an attribute value, which is never + // parsed as HTML. Confirm it round-trips intact. + expect(element.getAttribute("keyword")).toBe(payload); + expect(element.children.length).toBe(0); + // @ts-expect-error test probe + expect(window.__xssFired).toBeUndefined(); + }); +});
app/client/src/utils/autocomplete/keywordHintRenderer.ts+32 −0 added@@ -0,0 +1,32 @@ +/** + * Renders a keyword/snippet label into a CodeMirror hint `<li>` in a way + * that is safe against HTML injection. + * + * All autocomplete renderers that write untrusted (or potentially + * untrusted) strings into the hint DOM must go through this helper + * instead of assigning `innerHTML`. The consolidation also replaces the + * 14 duplicate inline renderers that previously existed in + * `CodemirrorTernService.ts` and `keywordCompletion.ts`, which all + * repeated `element.setAttribute("keyword", …); element.innerHTML = …`. + * + * See GHSA-vjfq-fvfc-3vjw: the SQL hint renderer used the same sink + * pattern against database-sourced identifiers and was exploited for + * stored XSS. The keyword autocomplete renderers are not independently + * exploitable today (the label is gated by a JS-keyword switch), but + * normalising them to `textContent` eliminates the pattern from the + * autocomplete subsystem. + * + * @param element The `<li>` element produced by CodeMirror's hint addon. + * @param label The primary text shown inside the entry (e.g. "for"). + * @param description Optional human-readable tag rendered next to the + * entry via the CSS rule `.CodeMirror-Tern-completion-keyword[keyword]:after + * { content: attr(keyword); }`. Defaults to `label`. + */ +export function renderKeywordHint( + element: HTMLElement, + label: string, + description?: string, +): void { + element.setAttribute("keyword", description ?? label); + element.textContent = label; +}
c4c93037dd6efix(security): block SSRF via send-test-email SMTP host validation (GHSA-vvxf-f8q9-86gh) (#41666)
4 files changed · +200 −6
app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java+52 −0 modified@@ -31,6 +31,7 @@ import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Optional; import java.util.Set; @Slf4j @@ -198,6 +199,57 @@ protected AddressResolver<InetSocketAddress> newResolver(EventExecutor executor) } } + /** + * Resolves a hostname and validates that none of its addresses are disallowed for + * outbound connections from non-HTTP paths (e.g. SMTP via JavaMail). Checks against + * the cloud-metadata denylist, loopback, link-local, any-local, multicast, and IPv6 + * Unique Local Addresses (fc00::/7). Returns the first validated resolved address so + * callers can connect to it directly, preventing DNS-rebinding TOCTOU bypasses. + * + * <p>RFC 1918 site-local ranges (10/8, 172.16/12, 192.168/16) are intentionally + * allowed because legitimate SMTP servers frequently reside on private networks. + * + * @return the resolved {@link InetAddress} if the host is allowed, or empty if blocked + */ + public static Optional<InetAddress> resolveIfAllowed(String host) { + if (!StringUtils.hasText(host)) { + return Optional.empty(); + } + + final String canonicalHost = normalizeHostForComparisonQuietly(host); + + if (DISALLOWED_HOSTS.contains(canonicalHost)) { + return Optional.empty(); + } + + final InetAddress[] resolved; + try { + resolved = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + return Optional.empty(); + } + + for (InetAddress addr : resolved) { + 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()) { + return Optional.empty(); + } + } + + return Optional.of(resolved[0]); + } + public static boolean isDisallowedAndFail(String host, Promise<?> promise) { if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(host))) { log.warn("Host {} is disallowed. Failing the request.", host);
app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java+51 −0 modified@@ -1,5 +1,6 @@ package com.appsmith.util; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpMethod; @@ -8,8 +9,10 @@ import reactor.test.StepVerifier; import java.lang.reflect.Method; +import java.net.InetAddress; import java.net.URI; import java.net.UnknownHostException; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -43,6 +46,54 @@ public void testIsDisallowedAndFailNormalizesMetadataHostnames(String host) { assertTrue(WebClientUtils.isDisallowedAndFail(host, null)); } + @ParameterizedTest + @ValueSource( + strings = { + "127.0.0.1", + "169.254.169.254", + "169.254.10.10", + "100.100.100.200", + "168.63.129.16", + "0.0.0.0", + }) + public void resolveIfAllowed_blocksLoopbackMetadataAndSpecialHosts(String host) { + Optional<InetAddress> result = WebClientUtils.resolveIfAllowed(host); + assertTrue(result.isEmpty(), "Expected host " + host + " to be blocked"); + } + + @Test + public void resolveIfAllowed_blocksNullAndEmpty() { + assertTrue(WebClientUtils.resolveIfAllowed(null).isEmpty()); + assertTrue(WebClientUtils.resolveIfAllowed("").isEmpty()); + assertTrue(WebClientUtils.resolveIfAllowed(" ").isEmpty()); + } + + @Test + public void resolveIfAllowed_blocksLocalhostHostname() { + Optional<InetAddress> result = WebClientUtils.resolveIfAllowed("localhost"); + assertTrue(result.isEmpty(), "Expected 'localhost' to be blocked"); + } + + @ParameterizedTest + @ValueSource(strings = {"smtp.gmail.com", "email-smtp.us-east-1.amazonaws.com", "smtp.sendgrid.net"}) + public void resolveIfAllowed_allowsLegitimateSmtpHosts(String host) { + Optional<InetAddress> result = WebClientUtils.resolveIfAllowed(host); + assertTrue(result.isPresent(), "Expected host " + host + " to be allowed"); + } + + @Test + public void resolveIfAllowed_blocksUnresolvableHost() { + Optional<InetAddress> result = WebClientUtils.resolveIfAllowed("definitely-not-a-real-host-xyz123.invalid"); + assertTrue(result.isEmpty(), "Expected unresolvable host to be blocked"); + } + + @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+")); + } + @SuppressWarnings("unchecked") private Mono<ClientRequest> invokeRequestFilterFn(String url) throws Exception { final Method method = WebClientUtils.class.getDeclaredMethod("requestFilterFn", ClientRequest.class);
app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java+48 −6 modified@@ -28,6 +28,7 @@ import com.appsmith.server.services.PermissionGroupService; import com.appsmith.server.services.SessionUserService; import com.appsmith.server.services.UserService; +import com.appsmith.util.WebClientUtils; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.mail.MessagingException; @@ -783,18 +784,59 @@ public Mono<Void> restartWithoutAclCheck() { return Mono.empty(); } + private static final Set<Integer> DEFAULT_SMTP_PORTS = Set.of(25, 465, 587, 2525); + + private static final Set<Integer> ALLOWED_SMTP_PORTS = computeAllowedSmtpPorts(); + + private static Set<Integer> computeAllowedSmtpPorts() { + Set<Integer> ports = new HashSet<>(DEFAULT_SMTP_PORTS); + String extra = System.getenv("APPSMITH_MAIL_ALLOWED_PORTS"); + if (extra != null && !extra.isBlank()) { + for (String token : extra.split(",")) { + try { + int port = Integer.parseInt(token.trim()); + if (port > 0 && port <= 65535) { + ports.add(port); + } + } catch (NumberFormatException ignored) { + } + } + } + return Set.copyOf(ports); + } + + private static final String SMTP_GENERIC_ERROR = + "Failed to connect to the SMTP server. Please verify the host, " + "port, and credentials are correct."; + @Override public Mono<Boolean> sendTestEmail(TestEmailConfigRequestDTO requestDTO) { return verifyCurrentUserIsSuper().flatMap(user -> { + if (!ALLOWED_SMTP_PORTS.contains(requestDTO.getSmtpPort())) { + return Mono.error( + new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP configuration.")); + } + + var resolvedAddress = WebClientUtils.resolveIfAllowed(requestDTO.getSmtpHost()); + if (resolvedAddress.isEmpty()) { + return Mono.error( + new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP configuration.")); + } + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); - mailSender.setHost(requestDTO.getSmtpHost()); + mailSender.setHost(resolvedAddress.get().getHostAddress()); mailSender.setPort(requestDTO.getSmtpPort()); Properties props = mailSender.getJavaMailProperties(); props.put("mail.transport.protocol", "smtp"); - props.put( - "mail.smtp.starttls.enable", requestDTO.getStarttlsEnabled().toString()); + if (requestDTO.getSmtpPort() == 465) { + props.put("mail.smtp.ssl.enable", "true"); + props.put("mail.smtp.starttls.enable", "false"); + } else { + props.put( + "mail.smtp.starttls.enable", + requestDTO.getStarttlsEnabled().toString()); + } props.put("mail.smtp.timeout", 7000); // 7 seconds @@ -817,15 +859,15 @@ public Mono<Boolean> sendTestEmail(TestEmailConfigRequestDTO requestDTO) { try { mailSender.testConnection(); } catch (MessagingException e) { - return Mono.error(new AppsmithException( - AppsmithError.GENERIC_BAD_REQUEST, e.getMessage().trim())); + log.error("SMTP test-connection failed for host {}", requestDTO.getSmtpHost(), e); + return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, SMTP_GENERIC_ERROR)); } try { mailSender.send(message); } catch (MailException mailException) { log.error("failed to send test email", mailException); - return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, mailException.getMessage())); + return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, SMTP_GENERIC_ERROR)); } return Mono.just(TRUE); });
app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java+49 −0 modified@@ -4,6 +4,7 @@ import com.appsmith.server.configurations.EmailConfig; import com.appsmith.server.configurations.GoogleRecaptchaConfig; import com.appsmith.server.domains.User; +import com.appsmith.server.dtos.TestEmailConfigRequestDTO; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.helpers.BlacklistedEnvVariableHelper; @@ -24,6 +25,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mail.javamail.JavaMailSender; @@ -384,6 +387,52 @@ public void sendTestEmail_WhenUserNotSuperUser_ThrowsException() { .verify(); } + private void mockSuperUser() { + User user = new User(); + user.setEmail("admin@appsmith.com"); + Mockito.when(userUtils.isCurrentUserSuperUser()).thenReturn(Mono.just(true)); + Mockito.when(sessionUserService.getCurrentUser()).thenReturn(Mono.just(user)); + } + + private TestEmailConfigRequestDTO buildDto(String smtpHost) { + return buildDto(smtpHost, 25); + } + + private TestEmailConfigRequestDTO buildDto(String smtpHost, int port) { + TestEmailConfigRequestDTO dto = new TestEmailConfigRequestDTO(); + dto.setSmtpHost(smtpHost); + dto.setSmtpPort(port); + dto.setFromEmail("test@appsmith.com"); + dto.setStarttlsEnabled(false); + return dto; + } + + @ParameterizedTest + @ValueSource(strings = {"127.0.0.1", "169.254.169.254", "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 configuration"); + }) + .verify(); + } + + @ParameterizedTest + @ValueSource(ints = {80, 443, 6379, 8080, 27017, 0, -1}) + public void sendTestEmail_WhenDisallowedPort_ThrowsException(int port) { + mockSuperUser(); + + StepVerifier.create(envManager.sendTestEmail(buildDto("smtp.gmail.com", port))) + .expectErrorSatisfies(e -> { + assertThat(e).isInstanceOf(AppsmithException.class); + assertThat(e.getMessage()).contains("Invalid SMTP configuration"); + }) + .verify(); + } + @Test public void setEnv_AndGetAll() { // Create a test map of environment variables
Vulnerability mechanics
Root cause
"The SQL query editor's autocomplete functionality failed to sanitize database object names before rendering them using `innerHTML`, allowing for persistent XSS."
Attack vector
An authenticated Developer can create a table or column with a malicious name containing an HTML payload. When any other workspace member interacts with the same data source and triggers the autocomplete functionality, the malicious name is rendered via `innerHTML`, executing arbitrary code in the victim's session [ref_id=2]. This can lead to session cookie theft, exfiltration of datasource credentials, or unauthorized actions within the victim's session [ref_id=2].
Affected code
The vulnerability exists in `app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts`, specifically where database object names are rendered using `innerHTML` [ref_id=2]. The fix involves changing this to `textContent` and consolidating rendering logic into `app/client/src/utils/autocomplete/keywordHintRenderer.ts` [patch_id=4518405].
What the fix does
The patch modifies the SQL hint renderer to use `textContent` instead of `innerHTML` when displaying database object names [patch_id=4518405]. This change prevents any HTML or script payloads within table or column names from being parsed and executed by the browser. Additionally, similar `innerHTML` sinks in other autocomplete renderers were consolidated into a shared `renderKeywordHint` helper function, ensuring a consistent and safe rendering pattern across the autocomplete subsystem [patch_id=4518405].
Preconditions
- authThe attacker must be an authenticated Developer within a workspace.
- inputThe attacker must have the ability to run DDL statements against a shared data source to create tables or columns with malicious names.
Generated on Jun 2, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.