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
50 changes: 50 additions & 0 deletions src/components/Toast/Toast.sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @vitest-environment jsdom
*
* Sanitization is verified against the REAL DOMPurify (needs a DOM, hence the
* jsdom environment) — the allow-list wiring is covered separately in
* Toast.test.ts with a stubbed DOMPurify.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { VNode } from 'vue'

const sonnerSpy = Object.assign(vi.fn(), {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
})

vi.mock('vue-sonner', () => ({ toast: sonnerSpy }))

const { toast } = await import('./toast')

// renderSafeHTML returns `() => h('span', { innerHTML })`. Pull that render
// function off the sonner spy, invoke it, and read the sanitized markup back.
function sanitizedHTML(): string {
const [message] = sonnerSpy.success.mock.calls[0]!
const vnode = (message as () => VNode)()
return (vnode.props as { innerHTML: string }).innerHTML
}

beforeEach(() => sonnerSpy.success.mockClear())

describe('Toast v1 — DOMPurify stripping', () => {
it('strips tags outside the allow-list while keeping their text content', () => {
toast.success('<strong>safe</strong><div>nested</div>')
const html = sanitizedHTML()
expect(html).toContain('<strong>safe</strong>')
expect(html).not.toContain('<div>')
expect(html).toContain('nested')
})

it('removes script and event-handler payloads to prevent XSS', () => {
toast.success('<b>ok</b><img src=x onerror=alert(1)><script>alert(2)<\/script>')
const html = sanitizedHTML()
expect(html).toContain('<b>ok</b>')
expect(html).not.toContain('<img')
expect(html).not.toContain('onerror')
expect(html).not.toContain('<script')
})
})
5 changes: 5 additions & 0 deletions src/components/Toast/Toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ const sonnerSpy = Object.assign(vi.fn(), {

vi.mock('vue-sonner', () => ({ toast: sonnerSpy }))

// Every toast path runs through renderSafeHTML → DOMPurify.sanitize, which
// needs a DOM. Stub it with a passthrough so this node-environment file doesn't
// crash; the real sanitization is verified in Toast.sanitize.test.ts (jsdom).
vi.mock('dompurify', () => ({ default: { sanitize: (html: string) => html } }))

const { toast } = await import('./toast')

beforeEach(() => {
Expand Down
9 changes: 9 additions & 0 deletions src/components/Toast/ToastProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ import { Toaster } from 'vue-sonner'
height: auto;
}

/* In the collapsed stack vue-sonner clamps every non-front toast to the front
toast's height (--front-toast-height) and hides their content. The content is
hidden only on [data-styled='true'] toasts, which `unstyled` strips — so a
multiline toast behind a single-line one spills its extra lines out the top.
Replicate sonner's intended behavior and fade the collapsed back toasts out. */
[data-sonner-toast][data-expanded='false'][data-front='false'] > * {
opacity: 0;
}

.sonner-loading-wrapper {
position: static;
}
Expand Down
40 changes: 32 additions & 8 deletions src/components/Toast/toast.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import DOMPurify from 'dompurify'
import { h, isVNode, type Component, type VNode } from 'vue'
import { toast as sonnerToast } from 'vue-sonner'
import { warnDeprecated } from '../../utils/warnDeprecated'

type ToastType = 'success' | 'error' | 'warning' | 'info'

type SonnerData = Parameters<typeof sonnerToast>[1]

// Tags that are safe to render inside a toast message. Anything outside this set is stripped by DOMPurify.
const ALLOWED_TAGS = ['a', 'em', 'strong', 'i', 'b', 'u']

// Sonner renders a string message as plain text and a VNode via
// `<component :is>`. To render safe HTML we sanitize the string and return a
// render function; non-string values (already VNodes/components) pass through.
function renderSafeHTML<T>(message: T): T | (() => VNode) {
if (typeof message !== 'string') return message
const html = DOMPurify.sanitize(message, { ALLOWED_TAGS })
return () => h('span', { innerHTML: html })
}

interface LegacyCreateOptions {
id?: string | number
message: string
Expand Down Expand Up @@ -62,20 +77,21 @@ function isLegacyObject(arg: unknown): arg is LegacyToastObject {

function dispatch(
type: ToastType | undefined,
message: string,
data: Parameters<typeof sonnerToast>[1],
message: string | Component | VNode,
data: SonnerData,
) {
const safeMessage = renderSafeHTML(message)
switch (type) {
case 'success':
return sonnerToast.success(message, data)
return sonnerToast.success(safeMessage, data)
case 'error':
return sonnerToast.error(message, data)
return sonnerToast.error(safeMessage, data)
case 'warning':
return sonnerToast.warning(message, data)
return sonnerToast.warning(safeMessage, data)
case 'info':
return sonnerToast.info(message, data)
return sonnerToast.info(safeMessage, data)
default:
return sonnerToast(message, data)
return sonnerToast(safeMessage, data)
}
}

Expand Down Expand Up @@ -107,7 +123,7 @@ function toastFn(
if (isLegacyObject(message)) {
return callLegacyObject(message)
}
return sonnerToast(message as string, options)
return sonnerToast(renderSafeHTML(message as string), options)
}

function create(options: LegacyCreateOptions) {
Expand Down Expand Up @@ -142,6 +158,14 @@ function removeAll() {
}

export const toast = Object.assign(toastFn, sonnerToast, {
success: (message: string | Component | VNode, data?: SonnerData) =>
dispatch('success', message, data),
error: (message: string | Component | VNode, data?: SonnerData) =>
dispatch('error', message, data),
warning: (message: string | Component | VNode, data?: SonnerData) =>
dispatch('warning', message, data),
info: (message: string | Component | VNode, data?: SonnerData) =>
dispatch('info', message, data),
create,
remove,
removeAll,
Expand Down
Loading