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 - + diff --git a/packages/gea-ui/src/components/toast.tsx b/packages/gea-ui/src/components/toast.tsx index b127666a..c9d8172f 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 @@ -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 @@ -74,57 +88,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' - - let inner = '
' - if (t.title) inner += `
${t.title}
` - if (t.description) - inner += `
${t.description}
` - inner += '
' - inner += - '' - div.innerHTML = inner - 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 @@ -228,7 +198,32 @@ export class Toaster extends Component {
+ > + {this._currentToasts.map((t: any) => ( +
+
+ {t.title && ( +
+ {escapeHTML(t.title)} +
+ )} + {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 new file mode 100644 index 00000000..f71fa6bf --- /dev/null +++ b/packages/gea-ui/tests/toast-xss.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { JSDOM } from 'jsdom' +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { transformSync } from 'esbuild' + +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 + + const Toaster = await loadToaster() + const toaster = new Toaster() + + const maliciousPayload = "" + const toastData = { + id: 'test-toast', + title: 'Safe Title', + description: maliciousPayload + } + + // Set internal state + toaster._currentToasts = [toastData] + + // Check template output + const html = toaster.template({}) + + // 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() + delete (globalThis as any).window + delete (globalThis as any).document + delete (globalThis as any).HTMLElement +})