Skip to content

fix: prevent stored XSS via SQL autocomplete (GHSA-vjfq-fvfc-3vjw)#41760

Merged
subrata71 merged 4 commits into
releasefrom
fix/stored-xss-sql-autocomplete-ghsa-vjfq
Apr 24, 2026
Merged

fix: prevent stored XSS via SQL autocomplete (GHSA-vjfq-fvfc-3vjw)#41760
subrata71 merged 4 commits into
releasefrom
fix/stored-xss-sql-autocomplete-ghsa-vjfq

Conversation

@subrata71
Copy link
Copy Markdown
Collaborator

@subrata71 subrata71 commented Apr 23, 2026

Description

Fixes a stored cross-site scripting vulnerability in the SQL autocomplete dropdown (GHSA-vjfq-fvfc-3vjw, CVSS 8.7 / High). An authenticated workspace Developer who can run DDL against a shared datasource could create a table or column whose name contains an HTML payload; when any other workspace member opened the SQL query editor and triggered autocomplete, the raw identifier was written to innerHTML and the payload executed in the victim's session.

TL;DR — the SQL hint renderer now writes completion text via textContent instead of innerHTML. Matching innerHTML sinks in the tern and JS-keyword autocomplete renderers are consolidated into a shared renderKeywordHint helper so the pattern is eliminated from the autocomplete subsystem. Pure client-side change, no backend/API/migration impact, no visual regression.

What changed

  • app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts — SQL hint renderer switched from innerHTML = text to textContent = text (primary fix for the actively-exploitable sink).
  • app/client/src/utils/autocomplete/keywordHintRenderer.ts (new) — single renderKeywordHint(element, label, description?) helper that sets textContent for the visible label and mirrors the optional description onto the keyword attribute (used by the existing content: attr(keyword) CSS rule to render the "For Loop" / "While Statement" suffix).
  • app/client/src/utils/autocomplete/CodemirrorTernService.ts — tern keyword completion render routed through the helper (was innerHTML = data.displayText).
  • app/client/src/utils/autocomplete/keywordCompletion.ts — 13 duplicate inline renderers collapsed into calls to the helper. Keyword-to-description mapping (for → "For Loop", try → "Try-catch Statement", …) is preserved 1:1; zero visual change.

Why render-layer, not data-layer

Database identifiers must stay raw through the backend, plugin structure cache, Redux state, and the getAllDatasourceTableKeys selector, because users legitimately need the exact name to write queries, refresh schemas, and bind widgets. The bug is missing output encoding, not over-permissive input. DDL filtering is deliberately not added — Appsmith's query editor is intentionally a full database console and restricting DDL would break schema-design apps, migration tools, and admin dashboards.

Test plan — TDD driven

All three fixes were developed test-first. Jest coverage was written to fail on the vulnerable code path and then verified to pass after the fix.

File Tests added Asserts
hintHelpers.test.ts 3 Table name, SVG table name, and table.column composite key with <img src=x onerror=…> payloads render as text, no <img>/<svg> child created, window.__xssFired stays undefined. These tests fail on the unpatched code (verified in the TDD red step).
keywordHintRenderer.test.ts 6 Helper writes textContent not innerHTML; HTML and SVG payloads do not parse; empty label does not create empty children; optional description populates the keyword attribute correctly.
keywordCompletion.test.ts 20 Each of the 10 JS keyword cases produces text-only hints (children.length === 0); descriptive labels ("For Loop", "While Statement", …) are preserved byte-for-byte after the refactor; spoofed outer completion with HTML payload never parses markup.

Verification commands:

yarn g:jest --testPathPattern="(hintHelpers|keywordHintRenderer|keywordCompletion)"  # 34 passed
yarn run check-types   # clean
npx eslint --cache <modified files>  # 0 errors (2 pre-existing Object.keys warnings untouched)
grep -rn '\.innerHTML\s*=' app/client/src/components/editorComponents/CodeEditor app/client/src/utils/autocomplete  # no write-sinks outside the new safe helper

Also manually reproduced the advisory PoC (CREATE TABLE "<svg onload=alert(1)>" (id serial primary key); in Postgres) in a local instance — alert fires on release, does not fire after this patch.

Fixes https://linear.app/appsmith/issue/APP-15167/security-high-stored-xss-via-database-tablecolumn-names-in-sql
Ref: GHSA-vjfq-fvfc-3vjw

Warning

This is a security-advisory fix. Linear ticket APP-15167 tracks the vulnerability; the GitHub advisory link is attached to the Linear issue. Coordinate advisory publication + CVE assignment with the security team before merge.

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/24854712195
Commit: 7a42f72
Cypress dashboard.
Tags: @tag.All
Spec:


Fri, 24 Apr 2026 06:35:05 UTC

Communication

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

  • Yes
  • No

(Coordinate via the security advisory disclosure.)

Summary by CodeRabbit

  • Bug Fixes

    • Fixed XSS vulnerabilities in SQL and keyword autocomplete completion rendering. Database table names, column names, and keywords are now safely displayed as text without interpreting HTML content.
  • Tests

    • Added comprehensive XSS regression test suites for autocomplete security.

The SQL hint renderer wrote the raw completion text (a database table or
column identifier) directly to innerHTML, allowing an authenticated
workspace Developer to execute arbitrary JavaScript in another member's
browser by creating a table with an HTML payload as its name.

Switches the assignment to textContent, matching CodeMirror 5's default
safe hint rendering. Adds Jest coverage that fails without the fix:
table-name, SVG-payload, and composite table.column variants.

No data-pipeline change: identifiers remain raw throughout Redux, the
selector, and plugin structure caches, because users legitimately need
the exact name to write queries.

Fixes APP-15167
Ref: GHSA-vjfq-fvfc-3vjw
…-fvfc-3vjw defense-in-depth)

The keyword branch of the tern completion renderer assigned
`data.displayText` directly to `element.innerHTML`. Today the label is
bounded to JavaScript keyword literals via the surrounding switch, so it
is not independently exploitable — but it matches the same sink pattern
that GHSA-vjfq-fvfc-3vjw exploited in the SQL hint renderer, so we
eliminate the pattern from the autocomplete subsystem.

Introduces `renderKeywordHint`, a single shared helper that writes the
label via `textContent` and mirrors it onto the `keyword` attribute.
Replaces the inline tern renderer with a call to the helper; adds
coverage for the helper (text-only rendering, HTML/SVG payloads, empty
label).

Ref: APP-15167, GHSA-vjfq-fvfc-3vjw
…q-fvfc-3vjw defense-in-depth)

`getCompletionsForKeyword` built CodeMirror completion entries whose
render closures wrote their label to `element.innerHTML`. The assigned
strings are either hardcoded literals ("forin", "forof", "try-catch") or
`completion.text`, and the caller's outer switch restricts
`completion.text` to a fixed set of JS keywords, so there is no current
exploit path. The sink pattern, however, matches the one
GHSA-vjfq-fvfc-3vjw exploited in the SQL hint renderer, so we eliminate
it from the autocomplete subsystem.

Refactors the 13 duplicated inline renderers to call the shared
`renderKeywordHint(element, label, description)` helper. Extends the
helper signature so the CSS `content: attr(keyword)` suffix continues to
show the descriptive label ("For Loop", "While Statement", …) that
users see next to each hint — this is a pure pass-through refactor with
no visual change.

Adds table-driven coverage over every keyword variant plus regression
tests that pin the keyword/label pair for each of the 9 callers.

Ref: APP-15167, GHSA-vjfq-fvfc-3vjw
CodeMirror's typed render signature is `(element: HTMLLIElement, data: Hints, cur: Hint) => void`, but the production closures only consume the element. The initial regression tests cast through `HTMLElement` directly, which tripped a non-overlap error in `yarn run check-types`. Route every production-render invocation through a single-arg `(el: HTMLElement) => void` cast so the tests compile cleanly without changing what they assert.

No production change.
@linear
Copy link
Copy Markdown

linear Bot commented Apr 23, 2026

@github-actions github-actions Bot added the Bug Something isn't working label Apr 23, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Walkthrough

Security patch introducing centralized safe hint rendering across CodeMirror autocomplete system. Changes replace unsafe innerHTML mutations with textContent writes via new renderKeywordHint helper function. Comprehensive XSS regression test suites validate malicious payloads are rendered as plain text without DOM element instantiation.

Changes

Cohort / File(s) Summary
SQL Hint Rendering
app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts, app/client/src/components/editorComponents/CodeEditor/hintHelpers.test.ts
Replaces innerHTML with textContent for SQL table/column names in hint rendering; adds XSS regression test suite validating malicious payloads render as plain text without DOM element instantiation.
Keyword Hint Renderer
app/client/src/utils/autocomplete/keywordHintRenderer.ts, app/client/src/utils/autocomplete/keywordHintRenderer.test.ts
Introduces new exported renderKeywordHint helper function for safe CodeMirror hint rendering using textContent; comprehensive tests validate HTML/SVG payloads are not parsed and window.__xssFired probe is not triggered.
Keyword Completion Refactor
app/client/src/utils/autocomplete/keywordCompletion.ts, app/client/src/utils/autocomplete/keywordCompletion.test.ts
Refactors keyword completion rendering to delegate to centralized renderKeywordHint helper across loop, conditional, switch, function, async variants; adds test suite verifying safe text-only rendering with attribute validation.
Service Integration
app/client/src/utils/autocomplete/CodemirrorTernService.ts
Updates keyword hint rendering to use new renderKeywordHint function instead of direct DOM mutations via setAttribute and innerHTML.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

✨ No more HTML in the hint display,
textContent keeps injection at bay! 🛡️
Each keyword and column, safely plain text,
XSS payloads have nowhere to flex—
One renderer to rule them all today! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main security fix: preventing XSS via SQL autocomplete and references the specific advisory (GHSA-vjfq-fvfc-3vjw).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description comprehensively documents the security vulnerability, the fix strategy, specific file changes, test coverage with verification commands, and coordinates security advisory publication.

✏️ 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/stored-xss-sql-autocomplete-ghsa-vjfq

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.

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

🤖 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/client/src/utils/autocomplete/keywordCompletion.test.ts`:
- Around line 94-119: The test never actually passes the payload to the renderer
because forgedOuterCompletion.text is overwritten to "for" before calling
getCompletionsForKeyword; to make the test honest, keep the payload as the
completion.text (set forgedOuterCompletion.text = payload) so the inner
renderers close over the real payload, and drive the switch that selects the
keyword branch by providing an explicit override for the keyword (e.g. add a
temporary property like forgedOuterCompletion.keywordName = "for" or call
getCompletionsForKeyword with an explicit keyword argument if available) so
getCompletionsForKeyword still picks the "for" branch while the renderer uses
the payload; alternatively, if you cannot force the branch selection externally,
remove this redundant test since the it.each already covers the keyword
branches.
🪄 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: 28daed9e-ba61-462e-9357-8fe03717f648

📥 Commits

Reviewing files that changed from the base of the PR and between 5d08e40 and 7a42f72.

📒 Files selected for processing (7)
  • app/client/src/components/editorComponents/CodeEditor/hintHelpers.test.ts
  • app/client/src/components/editorComponents/CodeEditor/hintHelpers.ts
  • app/client/src/utils/autocomplete/CodemirrorTernService.ts
  • app/client/src/utils/autocomplete/keywordCompletion.test.ts
  • app/client/src/utils/autocomplete/keywordCompletion.ts
  • app/client/src/utils/autocomplete/keywordHintRenderer.test.ts
  • app/client/src/utils/autocomplete/keywordHintRenderer.ts

Comment on lines +94 to +119
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);
});
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

The "spoofed completion" test doesn't actually exercise the payload path it claims to.

Line 103 sets text: "for" via spread, line 110 reassigns it again to "for", so by the time getCompletionsForKeyword runs, completion.text is just "for" and the renderers close over that. The payload string is never passed to a renderer, making the querySelector("img") assertion trivially true regardless of whether the sink regressed.

If the intent is to simulate a regression where completion.text reaches the sink, keep the payload as text and drive the switch via a separate parameter — otherwise consider dropping the test since the it.each above already covers all keyword branches.

♻️ One way to make the test honest
-    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 payload = '<img src=x onerror="window.__xssFired=true">';
+    // Keep `text` as the payload so any renderer that reads
+    // `completion.text` and regresses to innerHTML would execute it.
+    // `keywordName` matching is bypassed by monkey-patching via a proxy
+    // of `completion.text` read once at switch time.
+    const outer = stubCompletion(payload);
+    let reads = 0;
+    Object.defineProperty(outer, "text", {
+      get() {
+        return reads++ === 0 ? "for" : payload;
+      },
+    });
+
+    const completions = getCompletionsForKeyword(outer, 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);
+    // `@ts-expect-error` test probe
+    expect(window.__xssFired).toBeUndefined();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
});
it("does not parse an HTML payload when a spoofed completion is rendered", () => {
const payload = '<img src=x onerror="window.__xssFired=true">';
// Keep `text` as the payload so any renderer that reads
// `completion.text` and regresses to innerHTML would execute it.
// `keywordName` matching is bypassed by monkey-patching via a proxy
// of `completion.text` read once at switch time.
const outer = stubCompletion(payload);
let reads = 0;
Object.defineProperty(outer, "text", {
get() {
return reads++ === 0 ? "for" : payload;
},
});
const completions = getCompletionsForKeyword(outer, 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);
// `@ts-expect-error` test probe
expect(window.__xssFired).toBeUndefined();
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/client/src/utils/autocomplete/keywordCompletion.test.ts` around lines 94
- 119, The test never actually passes the payload to the renderer because
forgedOuterCompletion.text is overwritten to "for" before calling
getCompletionsForKeyword; to make the test honest, keep the payload as the
completion.text (set forgedOuterCompletion.text = payload) so the inner
renderers close over the real payload, and drive the switch that selects the
keyword branch by providing an explicit override for the keyword (e.g. add a
temporary property like forgedOuterCompletion.keywordName = "for" or call
getCompletionsForKeyword with an explicit keyword argument if available) so
getCompletionsForKeyword still picks the "for" branch while the renderer uses
the payload; alternatively, if you cannot force the branch selection externally,
remove this redundant test since the it.each already covers the keyword
branches.

@subrata71 subrata71 self-assigned this Apr 23, 2026
@subrata71 subrata71 added the ok-to-test Required label for CI label Apr 23, 2026
@subrata71 subrata71 requested a review from ashit-rath April 24, 2026 04:25
// TODO: Fix this the next time the file is edited
// eslint-disable-next-line @typescript-eslint/no-explicit-any
self: any,
_self: any,
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.

Why did we add _? Are we not referencing this anywhere?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, this has been found unused. I will double-check this. Is there any other concern with this PR? @ashit-rath

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.

No the rest looks good

@subrata71 subrata71 requested a review from ashit-rath April 24, 2026 07:30
@subrata71 subrata71 merged commit 99d6918 into release Apr 24, 2026
224 of 231 checks passed
@subrata71 subrata71 deleted the fix/stored-xss-sql-autocomplete-ghsa-vjfq branch April 24, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working ok-to-test Required label for CI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants