Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion examples/showcase/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -433,13 +433,23 @@ export default class App extends Component {
>
Error
</Button>
<Button
<Button
size="sm"
variant="outline"
click={() => ToastStore.info({ title: 'Tip', description: 'Try keyboard shortcuts.' })}
>
Info
</Button>
<Button
size="sm"
variant="destructive"
click={() => ToastStore.create({
title: 'XSS Test',
description: '<img src=x onerror=\'alert("XSS_FAIL")\'>'
})}
>
XSS Test
</Button>
</div>
</div>
</div>
Expand Down
89 changes: 42 additions & 47 deletions packages/gea-ui/src/components/toast.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any>): Record<string, any> {
const { style: _style, ...rest } = props
Expand All @@ -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 '&amp;'
case '<': return '&lt;'
case '>': return '&gt;'
case '"': return '&quot;'
case "'": return '&#39;'
default: return m
}
})
}

export class ToastStore {
static getStore = getStore

Expand Down Expand Up @@ -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 = '<div class="grid gap-1">'
if (t.title) inner += `<div data-part="title" class="toast-title text-sm font-semibold">${t.title}</div>`
if (t.description)
inner += `<div data-part="description" class="toast-description text-sm opacity-90">${t.description}</div>`
inner += '</div>'
inner +=
'<button data-part="close-trigger" class="toast-close-trigger text-foreground/50 hover:text-foreground">&#x2715;</button>'
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
Expand Down Expand Up @@ -228,7 +198,32 @@ export class Toaster extends Component {
<div
data-part="group"
class={`toaster fixed z-[100] flex max-h-screen flex-col-reverse gap-2 p-4 ${props.class || 'bottom-0 right-0'}`}
/>
>
{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>
)
}
}
117 changes: 117 additions & 0 deletions packages/gea-ui/tests/toast-xss.test.ts
Original file line number Diff line number Diff line change
@@ -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 += `</${tag}>`
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('<!doctype html><html><body></body></html>')
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 = "<img src=x onerror='alert(\"XSS_FAIL\")'>"
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('&lt;img'), 'Malicious tag should be escaped to &lt;img')
assert.ok(html.includes('onerror=&#39;alert(&quot;XSS_FAIL&quot;)&#39;'), 'Attributes should also be escaped')

// Cleanup
dom.window.close()
delete (globalThis as any).window
delete (globalThis as any).document
delete (globalThis as any).HTMLElement
})
Loading