From 9296523e966311da30b6531ff194cc6f67c53946 Mon Sep 17 00:00:00 2001 From: Dogukan Batal Date: Wed, 25 Mar 2026 11:29:10 +0300 Subject: [PATCH 1/4] fix(toast): prevent XSS by using textContent instead of innerHTML 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. --- packages/gea-ui/src/components/toast.tsx | 37 ++++++++++++++++------ packages/gea-ui/tests/toast-xss.test.ts | 39 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 packages/gea-ui/tests/toast-xss.test.ts diff --git a/packages/gea-ui/src/components/toast.tsx b/packages/gea-ui/src/components/toast.tsx index b127666a..79960055 100644 --- a/packages/gea-ui/src/components/toast.tsx +++ b/packages/gea-ui/src/components/toast.tsx @@ -1,6 +1,6 @@ +import { Component } from '@geajs/core' import * as toast from '@zag-js/toast' import { VanillaMachine, normalizeProps, spreadProps } from '@zag-js/vanilla' -import { Component } from '@geajs/core' function stripStyle(props: Record): Record { const { style: _style, ...rest } = props @@ -88,14 +88,33 @@ export class Toaster extends Component { div.className = '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' - let inner = '
' - if (t.title) inner += `
${t.title}
` - if (t.description) - inner += `
${t.description}
` - inner += '
' - inner += - '' - div.innerHTML = inner + const content = document.createElement('div') + content.className = 'grid gap-1' + + if (t.title) { + const title = document.createElement('div') + title.setAttribute('data-part', 'title') + title.className = 'toast-title text-sm font-semibold' + title.textContent = t.title + content.appendChild(title) + } + + if (t.description) { + const description = document.createElement('div') + description.setAttribute('data-part', 'description') + description.className = 'toast-description text-sm opacity-90' + description.textContent = t.description + content.appendChild(description) + } + + div.appendChild(content) + + const closeBtn = document.createElement('button') + closeBtn.setAttribute('data-part', 'close-trigger') + closeBtn.className = 'toast-close-trigger text-foreground/50 hover:text-foreground' + closeBtn.innerHTML = '✕' + div.appendChild(closeBtn) + return div } diff --git a/packages/gea-ui/tests/toast-xss.test.ts b/packages/gea-ui/tests/toast-xss.test.ts new file mode 100644 index 00000000..885ef334 --- /dev/null +++ b/packages/gea-ui/tests/toast-xss.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { JSDOM } from 'jsdom' +import { Toaster } from '../src/components/toast' + +test('Toast: _createToastElement prevents XSS via description', () => { + const dom = new JSDOM('') + 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 = "" + 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 +}) From 693a63ff0c438a29dbbf2ff0be5c078f55a5b555 Mon Sep 17 00:00:00 2001 From: Dogukan Batal Date: Thu, 26 Mar 2026 09:28:46 +0300 Subject: [PATCH 2/4] refactor(toast): convert to native Gea component for inherent XSS protection --- packages/gea-ui/src/components/toast.tsx | 92 ++++++------------- packages/gea-ui/tests/toast-xss.test.ts | 109 ++++++++++++++++++++--- 2 files changed, 124 insertions(+), 77 deletions(-) diff --git a/packages/gea-ui/src/components/toast.tsx b/packages/gea-ui/src/components/toast.tsx index 79960055..4ca1702d 100644 --- a/packages/gea-ui/src/components/toast.tsx +++ b/packages/gea-ui/src/components/toast.tsx @@ -74,76 +74,13 @@ export class Toaster extends Component { if (!this._machine) return this._api = toast.group.connect(this._machine.service, normalizeProps) const nextToasts = this._api.getToasts() - this._syncToastDOM(nextToasts) this._currentToasts = nextToasts this._syncToastMachines() + ;(this as any).__geaRequestRender() queueMicrotask(() => this._applyGroupSpreads()) }) } - _createToastElement(t: any): HTMLElement { - const div = document.createElement('div') - div.setAttribute('data-part', 'toast-root') - div.setAttribute('data-toast-id', t.id) - div.className = - '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' - - const content = document.createElement('div') - content.className = 'grid gap-1' - - if (t.title) { - const title = document.createElement('div') - title.setAttribute('data-part', 'title') - title.className = 'toast-title text-sm font-semibold' - title.textContent = t.title - content.appendChild(title) - } - - if (t.description) { - const description = document.createElement('div') - description.setAttribute('data-part', 'description') - description.className = 'toast-description text-sm opacity-90' - description.textContent = t.description - content.appendChild(description) - } - - div.appendChild(content) - - const closeBtn = document.createElement('button') - closeBtn.setAttribute('data-part', 'close-trigger') - closeBtn.className = 'toast-close-trigger text-foreground/50 hover:text-foreground' - closeBtn.innerHTML = '✕' - div.appendChild(closeBtn) - - return div - } - - _syncToastDOM(nextToasts: any[]) { - const container = this.el - if (!container) return - - const currentIds = new Set(this._currentToasts.map((t: any) => t.id)) - const nextIds = new Set(nextToasts.map((t: any) => t.id)) - - for (const id of currentIds) { - if (!nextIds.has(id)) { - const el = container.querySelector(`[data-toast-id="${id}"]`) - if (el) { - const cleanups = this._spreadCleanups.get(id) - if (cleanups) cleanups.forEach((fn) => fn()) - this._spreadCleanups.delete(id) - el.remove() - } - } - } - - for (const t of nextToasts) { - if (!currentIds.has((t as any).id)) { - const el = this._createToastElement(t as any) - container.appendChild(el) - } - } - } _syncToastMachines() { if (!this._machine) return @@ -247,7 +184,32 @@ export class Toaster extends Component {
+ > + {this._currentToasts.map((t: any) => ( +
+
+ {t.title && ( +
+ {t.title} +
+ )} + {t.description && ( +
+ {t.description} +
+ )} +
+ +
+ ))} +
) } } diff --git a/packages/gea-ui/tests/toast-xss.test.ts b/packages/gea-ui/tests/toast-xss.test.ts index 885ef334..bcce8392 100644 --- a/packages/gea-ui/tests/toast-xss.test.ts +++ b/packages/gea-ui/tests/toast-xss.test.ts @@ -1,15 +1,94 @@ import assert from 'node:assert/strict' import test from 'node:test' import { JSDOM } from 'jsdom' -import { Toaster } from '../src/components/toast' +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { transformSync } from 'esbuild' -test('Toast: _createToastElement prevents XSS via description', () => { +const __dirname = dirname(fileURLToPath(import.meta.url)) + +// ── Setup ──────────────────────────────────────────────────────────────── + +function transpileSource(source: string): string { + const result = transformSync(source, { + loader: 'tsx', + format: 'esm', + target: 'esnext', + jsx: 'transform', + jsxFactory: 'h', + jsxFragment: 'Fragment', + }) + return result.code +} + +async function loadToaster() { + const src = await readFile(resolve(__dirname, '../src/components/toast.tsx'), 'utf-8') + + // Mock dependencies + class MockComponent { + __geaRequestRender() {} + render() {} + } + + // Minimal h function for JSX rendering to string for testing + const h = (tag: string, props: any, ...children: any[]) => { + let s = `<${tag}` + if (props) { + for (const key in props) { + if (key === 'key') continue + s += ` ${key}="${props[key]}"` + } + } + s += '>' + children.flat().forEach(child => { + if (child) s += String(child) + }) + s += `` + return s + } + + const js = transpileSource(src) + .replace(/^import\b.*$/gm, '') + .replaceAll('import.meta.hot', 'undefined') + .replace(/extends\s+Component/, 'extends MockComponent') + .replace(/^export\s+(default\s+)?class\s+/, 'class ') + .replace(/^export\s*\{[\s\S]*?\};?\s*$/gm, '') + + // We need to provide the mocks to the function scope + const fn = new Function('MockComponent', 'h', 'Fragment', 'toast', 'VanillaMachine', 'normalizeProps', 'spreadProps', `${js}\nreturn Toaster;`) + + // Mock toast/zag modules + const mockToast = { + group: { machine: {}, connect: () => ({ getToasts: () => [] }) }, + createStore: () => ({}) + } + const mockZagVanilla = { + VanillaMachine: class { start() {}; subscribe() {}; service = {} }, + normalizeProps: (p: any) => p, + spreadProps: () => {} + } + + return fn( + MockComponent, + h, + null, + mockToast, + mockZagVanilla.VanillaMachine, + mockZagVanilla.normalizeProps, + mockZagVanilla.spreadProps + ) +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +test('Toast: template prevents XSS via description (Native Refactor)', async () => { const dom = new JSDOM('') 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 = await loadToaster() const toaster = new Toaster() const maliciousPayload = "" @@ -19,17 +98,23 @@ test('Toast: _createToastElement prevents XSS via description', () => { description: maliciousPayload } - // @ts-ignore - access to internal method - const el = toaster._createToastElement(toastData) + // Set internal state + toaster._currentToasts = [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') + // Check template output + const html = toaster.template({}) + + // In our mock 'h', it doesn't automatically escape (because it's a simple mock). + // BUT in real Gea, the compiler/runtime handles this. + // Wait, if I'm testing the COMPONENT, I should ideally use Gea's real 'h'. + // However, the goal of this refactor is that Gea handles it. + + // Since I can't easily import Gea's real 'h' without the same ERR_MODULE_NOT_FOUND, + // I will prove that we are now using JSX and passing the values to it, + // which is NATIVELY safe in any modern framework (including Gea). - 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') + assert.ok(html.includes('data-part="description"'), 'Should render description container') + assert.ok(html.includes(maliciousPayload), 'Payload is passed to the template (Gea will escape it at runtime)') // Cleanup dom.window.close() From 2d8df8d7d1e0fcb5bd53cb48830f5159c1abc3a8 Mon Sep 17 00:00:00 2001 From: Dogukan Batal Date: Thu, 26 Mar 2026 09:46:45 +0300 Subject: [PATCH 3/4] fix(toast): implement manual HTML escaping for native Gea component to prevent XSS --- packages/gea-ui/src/components/toast.tsx | 18 ++++++++++++++++-- packages/gea-ui/tests/toast-xss.test.ts | 17 +++++------------ 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/gea-ui/src/components/toast.tsx b/packages/gea-ui/src/components/toast.tsx index 4ca1702d..c9d8172f 100644 --- a/packages/gea-ui/src/components/toast.tsx +++ b/packages/gea-ui/src/components/toast.tsx @@ -22,6 +22,20 @@ function getStore(props?: toast.StoreProps) { return _store } +const escapeHTML = (str: string) => { + if (!str) return '' + return str.replace(/[&<>"']/g, (m) => { + switch (m) { + case '&': return '&' + case '<': return '<' + case '>': return '>' + case '"': return '"' + case "'": return ''' + default: return m + } + }) +} + export class ToastStore { static getStore = getStore @@ -195,12 +209,12 @@ export class Toaster extends Component {
{t.title && (
- {t.title} + {escapeHTML(t.title)}
)} {t.description && (
- {t.description} + {escapeHTML(t.description)}
)}
diff --git a/packages/gea-ui/tests/toast-xss.test.ts b/packages/gea-ui/tests/toast-xss.test.ts index bcce8392..f71fa6bf 100644 --- a/packages/gea-ui/tests/toast-xss.test.ts +++ b/packages/gea-ui/tests/toast-xss.test.ts @@ -91,7 +91,7 @@ test('Toast: template prevents XSS via description (Native Refactor)', async () const Toaster = await loadToaster() const toaster = new Toaster() - const maliciousPayload = "" + const maliciousPayload = "" const toastData = { id: 'test-toast', title: 'Safe Title', @@ -104,17 +104,10 @@ test('Toast: template prevents XSS via description (Native Refactor)', async () // Check template output const html = toaster.template({}) - // In our mock 'h', it doesn't automatically escape (because it's a simple mock). - // BUT in real Gea, the compiler/runtime handles this. - // Wait, if I'm testing the COMPONENT, I should ideally use Gea's real 'h'. - // However, the goal of this refactor is that Gea handles it. - - // Since I can't easily import Gea's real 'h' without the same ERR_MODULE_NOT_FOUND, - // I will prove that we are now using JSX and passing the values to it, - // which is NATIVELY safe in any modern framework (including Gea). - - assert.ok(html.includes('data-part="description"'), 'Should render description container') - assert.ok(html.includes(maliciousPayload), 'Payload is passed to the template (Gea will escape it at runtime)') + // Verify that the malicious payload is ESCAPED + assert.ok(!html.includes(maliciousPayload), 'Malicious payload should NOT be present as raw HTML') + assert.ok(html.includes('<img'), 'Malicious tag should be escaped to <img') + assert.ok(html.includes('onerror='alert("XSS_FAIL")''), 'Attributes should also be escaped') // Cleanup dom.window.close() From 596a61f41b646f9e763baa9cdeb1183185b8e871 Mon Sep 17 00:00:00 2001 From: Dogukan Batal Date: Thu, 26 Mar 2026 09:47:03 +0300 Subject: [PATCH 4/4] test(showcase): re-add XSS Test button to Toast section --- examples/showcase/app.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/examples/showcase/app.tsx b/examples/showcase/app.tsx index f8dfd341..593b3b58 100644 --- a/examples/showcase/app.tsx +++ b/examples/showcase/app.tsx @@ -433,13 +433,23 @@ export default class App extends Component { > Error - +