Skip to content

fix(toast): native gea refactor with manual HTML escaping for XSS#9

Open
dgknbtl wants to merge 4 commits intodashersw:mainfrom
dgknbtl:fix/toast-xss
Open

fix(toast): native gea refactor with manual HTML escaping for XSS#9
dgknbtl wants to merge 4 commits intodashersw:mainfrom
dgknbtl:fix/toast-xss

Conversation

@dgknbtl
Copy link
Copy Markdown

@dgknbtl dgknbtl commented Mar 25, 2026

Toast XSS Fix & Native Gea Refactor

Summary

This PR fixes a critical XSS vulnerability in the Toaster component by refactoring it into a Native Gea Component.

Changes

Toast: Native Gea Refactor & XSS Patch

Following the maintainer's recommendation, the Toaster component was refactored to use Gea's declarative template system:

  • Architecture: Replaced imperative DOM manipulation (_createToastElement, _syncToastDOM) with a declarative JSX structure using .map().
  • Security (XSS Patch): Converted to a native component and added a manual escapeHTML helper to properly sanitize title and description props, as the Gea JSX engine doesn't automatically sanitize strings.
  • Reactive Updates: Integration with Zag.js state is now handled natively via this.__geaRequestRender().

Verification

Automated Tests

Manual Verification

Confirmed via Showcase application:

  • Security: The "XSS Test" button triggers toasts that display raw HTML tags as plain text; no script execution (alerts) occurs.
  • UI/UX: Layout, styling, and animations remain intact. Closing/dismissing toasts works as expected.

Additional Notes

  • Breaking Changes: Internal refactor of Toaster. The external ToastStore API remains unchanged and backwards compatible.
  • No Dependencies: No third-party sanitization libraries were introduced.

This change replaces the vulnerable innerHTML assignment in the Toast component with a secure DOM manipulation approach. By using textContent and createElement, user-provided title and description data are rendered as plain text, preventing malicious script injection while maintaining accessibility and layout.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

📝 Walkthrough

Walkthrough

Refactors toast rendering from imperative DOM/innerHTML diffing to declarative templating with escaped content; updates subscription to request a re-render; adds an XSS safety test and a showcase button that triggers a malicious-description toast.

Changes

Cohort / File(s) Summary
Toast component
packages/gea-ui/src/components/toast.tsx
Removed _createToastElement and _syncToastDOM; added escapeHTML and switched to a template()-based declarative render producing toast roots, conditional title/description, and close triggers; subscription now calls __geaRequestRender() after updating _currentToasts; group container markup adjusted to wrap toast elements.
XSS security test
packages/gea-ui/tests/toast-xss.test.ts
New Node.js/jsdom test that loads/transpiles the Toaster, injects a toast with a malicious HTML payload in description, renders the template, and asserts the output contains escaped HTML (no raw executable markup).
Showcase example
examples/showcase/app.tsx
Added an “XSS Test” button that creates a toast with an <img onerror=...> payload to exercise the escaping behavior; minor whitespace/indentation adjustment for an existing button.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped in to tidy the nest,
Swapped innerHTML for escapes at best,
Templates now sing, no scripts can bite,
A safer toast popped up tonight! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title references both main changes: refactoring Toast to native Gea and implementing HTML escaping for XSS prevention, accurately capturing the actual changeset.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@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 (2)
packages/gea-ui/tests/toast-xss.test.ts (2)

7-39: Guard global cleanup with try/finally to avoid leaking test state on failures.

If any assertion fails before Line 35, globals remain mutated and can taint later tests.

Suggested change
-test('Toast: _createToastElement prevents XSS via description', () => {
+test('Toast: _createToastElement prevents XSS via description', () => {
   const dom = new JSDOM('<!doctype html><html><body></body></html>')
-  globalThis.window = dom.window as any
-  globalThis.document = dom.window.document
-  globalThis.HTMLElement = dom.window.HTMLElement
+  const prevWindow = (globalThis as any).window
+  const prevDocument = (globalThis as any).document
+  const prevHTMLElement = (globalThis as any).HTMLElement
+
+  try {
+    globalThis.window = dom.window as any
+    globalThis.document = dom.window.document
+    globalThis.HTMLElement = dom.window.HTMLElement
@@
-  // Cleanup
-  dom.window.close()
-  delete (globalThis as any).window
-  delete (globalThis as any).document
-  delete (globalThis as any).HTMLElement
+  } finally {
+    dom.window.close()
+    if (prevWindow === undefined) delete (globalThis as any).window
+    else (globalThis as any).window = prevWindow
+    if (prevDocument === undefined) delete (globalThis as any).document
+    else (globalThis as any).document = prevDocument
+    if (prevHTMLElement === undefined) delete (globalThis as any).HTMLElement
+    else (globalThis as any).HTMLElement = prevHTMLElement
+  }
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ui/tests/toast-xss.test.ts` around lines 7 - 39, Wrap the test
body that sets globals and uses Toaster/_createToastElement in a try/finally:
after creating dom and assigning globalThis.window, document and HTMLElement,
run the assertions inside try, and in the finally block call dom.window.close()
and delete the mutated globals (window, document, HTMLElement) to ensure cleanup
even if an assertion fails; reference Toaster and _createToastElement to locate
the test's core logic to surround with try/finally.

15-20: Consider adding a title-based payload assertion for full regression coverage.

This test validates description escaping; adding the same check for title would cover both escaped fields changed in the component.

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

In `@packages/gea-ui/tests/toast-xss.test.ts` around lines 15 - 20, Add the same
malicious payload check for the toast title: set toastData.title to
maliciousPayload (the same string used for description) and add an assertion
that the rendered title is escaped/contains the literal text rather than
executing HTML (e.g., ensure window.xssDetected remains undefined/false and/or
the title element's textContent contains the raw payload rather than an <img>
tag). Update the test that uses maliciousPayload and toastData so both title and
description fields are validated.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/gea-ui/tests/toast-xss.test.ts`:
- Around line 2-6: The test file imports the default test function from
'node:test' but the repository standard requires named imports; change the
import statement to use { describe, it } from 'node:test' and update the test
block to use describe(...) with an it(...) for the actual assertion (keeping
existing JSDOM and Toaster imports intact), ensuring the test name and body are
preserved when replacing the test(...) call with describe + it so the file uses
describe/it from 'node:test'.

---

Nitpick comments:
In `@packages/gea-ui/tests/toast-xss.test.ts`:
- Around line 7-39: Wrap the test body that sets globals and uses
Toaster/_createToastElement in a try/finally: after creating dom and assigning
globalThis.window, document and HTMLElement, run the assertions inside try, and
in the finally block call dom.window.close() and delete the mutated globals
(window, document, HTMLElement) to ensure cleanup even if an assertion fails;
reference Toaster and _createToastElement to locate the test's core logic to
surround with try/finally.
- Around line 15-20: Add the same malicious payload check for the toast title:
set toastData.title to maliciousPayload (the same string used for description)
and add an assertion that the rendered title is escaped/contains the literal
text rather than executing HTML (e.g., ensure window.xssDetected remains
undefined/false and/or the title element's textContent contains the raw payload
rather than an <img> tag). Update the test that uses maliciousPayload and
toastData so both title and description fields are validated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c4d31900-94a5-480d-94fd-635f041f0448

📥 Commits

Reviewing files that changed from the base of the PR and between f4b424b and 0e9ca48.

📒 Files selected for processing (2)
  • packages/gea-ui/src/components/toast.tsx
  • packages/gea-ui/tests/toast-xss.test.ts

Comment on lines +2 to +6
import test from 'node:test'
import { JSDOM } from 'jsdom'
import { Toaster } from '../src/components/toast'

test('Toast: _createToastElement prevents XSS via description', () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n packages/gea-ui/tests/toast-xss.test.ts

Repository: dashersw/gea

Length of output: 1745


Use describe/it imports from node:test per repo test standard.

Line 2 currently uses default test import; this test file must use { describe, it } named imports as required by repository testing guidelines.

Suggested change
 import assert from 'node:assert/strict'
-import test from 'node:test'
+import { describe, it } from 'node:test'
 import { JSDOM } from 'jsdom'
 import { Toaster } from '../src/components/toast'
 
-test('Toast: _createToastElement prevents XSS via description', () => {
+describe('Toast', () => {
+  it('_createToastElement prevents XSS via description', () => {
   const dom = new JSDOM('<!doctype html><html><body></body></html>')
   globalThis.window = dom.window as any
   globalThis.document = dom.window.document
   globalThis.HTMLElement = dom.window.HTMLElement
 
   // We can test the Toaster component directly
   const toaster = new Toaster()
   
   const maliciousPayload = "<img src=x onerror='window.xssDetected=true'>"
   const toastData = {
     id: 'test-toast',
     title: 'Safe Title',
     description: maliciousPayload
   }
 
   // `@ts-ignore` - access to internal method
   const el = toaster._createToastElement(toastData)
 
   // In JSDOM, when assigning textContent, the string is not parsed as HTML.
   // We check that the malicious img tag is NOT present as an element.
   const img = el.querySelector('img')
   assert.equal(img, null, 'Malicious img tag should NOT be rendered')
   
   const descEl = el.querySelector('[data-part="description"]')
   assert.ok(descEl, 'Description element should exist')
   assert.equal(descEl.textContent, maliciousPayload, 'Payload should be rendered as plain text')
 
   // Cleanup
   dom.window.close()
   delete (globalThis as any).window
   delete (globalThis as any).document
   delete (globalThis as any).HTMLElement
+  })
+})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ui/tests/toast-xss.test.ts` around lines 2 - 6, The test file
imports the default test function from 'node:test' but the repository standard
requires named imports; change the import statement to use { describe, it } from
'node:test' and update the test block to use describe(...) with an it(...) for
the actual assertion (keeping existing JSDOM and Toaster imports intact),
ensuring the test name and body are preserved when replacing the test(...) call
with describe + it so the file uses describe/it from 'node:test'.

@dashersw
Copy link
Copy Markdown
Owner

Isn't escaping the inputs a better idea?

@dgknbtl
Copy link
Copy Markdown
Author

dgknbtl commented Mar 25, 2026

Isn't escaping the inputs a better idea?

@dashersw, I used textContent because it's natively safe. Browser handles the encoding for me, so I don't need to manage it manually. We are only handling plain text here, so it’s a simple and clean way, I think.

@dashersw
Copy link
Copy Markdown
Owner

Why don't we convert this into a native Gea component? It can manage its own DOM with a .map on toasts, no?

@dgknbtl
Copy link
Copy Markdown
Author

dgknbtl commented Mar 26, 2026

I got it. I've converted it to a native Gea component. Note that I also had to add a manual escapeHTML helper because the Gea JSX engine doesn't automatically sanitize strings, which was necessary to fully fix the XSS.

Copy link
Copy Markdown

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/gea-ui/src/components/toast.tsx`:
- Around line 25-37: The escapeHTML function can throw if passed a non-string
truthy value (e.g., number); update escapeHTML to coerce the input to a string
before calling replace and still handle null/undefined/empty by returning ''. In
practice, inside escapeHTML (the function shown), first check for null/undefined
and return '', otherwise convert the value to String(str) (or use `${str}`) and
run the replace on that resulting string so numbers/objects with primitive
string representations are safe.
- Around line 201-226: In the toast renderer mapping over this._currentToasts,
remove the escapeHTML(...) wrappers around t.title and t.description so the JSX
renders the raw values (e.g., change escapeHTML(t.title) and
escapeHTML(t.description) to just t.title and t.description) — this ensures
Gea's renderer uses textContent/nodeValue automatic escaping instead of
double-escaping; locate the JSX inside the component where key={t.id} and
data-part="title"/"description" are rendered and update those expressions
accordingly.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: cecb23b6-5170-4e7c-8be6-d94c1262c3ea

📥 Commits

Reviewing files that changed from the base of the PR and between 0e9ca48 and 1427d04.

📒 Files selected for processing (3)
  • examples/showcase/app.tsx
  • packages/gea-ui/src/components/toast.tsx
  • packages/gea-ui/tests/toast-xss.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/gea-ui/tests/toast-xss.test.ts

Comment on lines +25 to +37
const escapeHTML = (str: string) => {
if (!str) return ''
return str.replace(/[&<>"']/g, (m) => {
switch (m) {
case '&': return '&amp;'
case '<': return '&lt;'
case '>': return '&gt;'
case '"': return '&quot;'
case "'": return '&#39;'
default: return m
}
})
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add type coercion to handle non-string values safely.

The falsy check handles null, undefined, and empty strings, but a truthy non-string value (e.g., a number) would cause a runtime error on .replace(). Since toast data may come from external sources, consider coercing to string first.

🛡️ Proposed fix
 const escapeHTML = (str: string) => {
-  if (!str) return ''
-  return str.replace(/[&<>"']/g, (m) => {
+  if (str == null) return ''
+  const s = String(str)
+  return s.replace(/[&<>"']/g, (m) => {
     switch (m) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/gea-ui/src/components/toast.tsx` around lines 25 - 37, The
escapeHTML function can throw if passed a non-string truthy value (e.g.,
number); update escapeHTML to coerce the input to a string before calling
replace and still handle null/undefined/empty by returning ''. In practice,
inside escapeHTML (the function shown), first check for null/undefined and
return '', otherwise convert the value to String(str) (or use `${str}`) and run
the replace on that resulting string so numbers/objects with primitive string
representations are safe.

Comment on lines +201 to +226
>
{this._currentToasts.map((t: any) => (
<div
key={t.id}
data-part="toast-root"
data-toast-id={t.id}
class="toast-root group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 shadow-lg transition-all bg-background text-foreground"
>
<div class="grid gap-1">
{t.title && (
<div data-part="title" class="toast-title text-sm font-semibold">
{escapeHTML(t.title)}
</div>
)}
{t.description && (
<div data-part="description" class="toast-description text-sm opacity-90">
{escapeHTML(t.description)}
</div>
)}
</div>
<button data-part="close-trigger" class="toast-close-trigger text-foreground/50 hover:text-foreground">
&#x2715;
</button>
</div>
))}
</div>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 26, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check how Gea framework handles text interpolation in JSX templates.
# Look for the renderer implementation that processes JSX children.

# Search for text node creation or innerHTML usage in the Gea core renderer
rg -n -A5 -B2 'textContent|innerHTML|createTextNode' --type=ts packages/gea/src/

# Also check how the compiler handles JSX text children
rg -n -A5 'text.*node|string.*child' --type=ts packages/gea/src/

Repository: dashersw/gea

Length of output: 1751


🌐 Web query:

Gea framework JSX text node textContent innerHTML escaping

💡 Result:

In the Gea framework, JSX text nodes (expressions like {store.count}) are automatically escaped for security, preventing XSS attacks by treating dynamic content as plain text rather than HTML. The Vite plugin compiles JSX to HTML template strings at build time and generates surgical DOM updates via observe calls on specific state paths. These updates target individual text nodes using safe methods like textContent or nodeValue, which escape HTML entities by spec (e.g., <script> renders as literal text). Gea docs do not explicitly detail innerHTML usage for text nodes; instead, its no-VDOM, direct DOM patching approach mirrors standard JS best practices where textContent ensures escaping. No evidence of innerHTML for text interpolation, confirming safe-by-default text node handling akin to React/Solid.

Citations:


🏁 Script executed:

#!/bin/bash
# Alternative: Try to fetch Gea documentation or package info
# Check if there's a README or documentation in the review context
fd -t f 'README|CHANGELOG|docs' -x grep -l 'textContent\|innerHTML\|escapeHTML' {}

# Also check for any existing tests that might reveal expected behavior
fd -t f 'test|spec' packages/gea-ui/src/ -name '*toast*' | head -5

Repository: dashersw/gea

Length of output: 1836


Remove escapeHTML() calls—Gea uses textContent natively with automatic HTML escaping.

Gea's JSX renderer targets text nodes using textContent and nodeValue, which escape HTML entities automatically per HTML spec. Calling escapeHTML() on t.title and t.description causes double-escaping: content like <img> becomes &lt;img&gt; displayed as literal text to users.

This contradicts the PR title's intent to use textContent. Simply remove the escapeHTML() wrapper:

{t.title && (
  <div data-part="title" class="toast-title text-sm font-semibold">
    {t.title}
  </div>
)}
{t.description && (
  <div data-part="description" class="toast-description text-sm opacity-90">
    {t.description}
  </div>
)}

Gea handles escaping automatically for safe-by-default text node handling.

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

In `@packages/gea-ui/src/components/toast.tsx` around lines 201 - 226, In the
toast renderer mapping over this._currentToasts, remove the escapeHTML(...)
wrappers around t.title and t.description so the JSX renders the raw values
(e.g., change escapeHTML(t.title) and escapeHTML(t.description) to just t.title
and t.description) — this ensures Gea's renderer uses textContent/nodeValue
automatic escaping instead of double-escaping; locate the JSX inside the
component where key={t.id} and data-part="title"/"description" are rendered and
update those expressions accordingly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

@dgknbtl why do you think this is still an issue? shouldn't we fix this at the library level?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@dashersw you're right, fixing this at the library/core level would be the best long-term solution. However, this fix for the Toast component solves the issue quickly and effectively without breaking the current architecture.

Technically, Gea's "No vDOM" philosophy provides good performance, but it also brings a security trade-off. I believe a core-level fix would require a major change in Gea's string based JSX runtime. That's why securing it at the component level while keeping the current structure is the most logical and fast step for now.

@dgknbtl
Copy link
Copy Markdown
Author

dgknbtl commented Mar 26, 2026

I've confirmed with a live test that Gea JSX doesn't auto-escape these strings (see attached XSS proof). To keep the component secure, I've kept the manual escapeHTML helper in the implementation.

xss

@dgknbtl dgknbtl changed the title fix(toast): prevent XSS by using textContent instead of innerHTML fix(toast): native gea refactor with manual HTML escaping for XSS Mar 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants