Unhead has a XSS bypass in `useHeadSafe` via attribute name injection and case-sensitive protocol check
Description
Unhead is a document head and template manager. Prior to 2.1.11, useHeadSafe() can be bypassed to inject arbitrary HTML attributes, including event handlers, into SSR-rendered <head> tags. This is the composable that Nuxt docs recommend for safely handling user-generated content. The acceptDataAttrs function (safe.ts, line 16-20) allows any property key starting with data- through to the final HTML. It only checks the prefix, not whether the key contains spaces or other characters that break HTML attribute parsing. This vulnerability is fixed in 2.1.11.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
unheadnpm | < 2.1.11 | 2.1.11 |
Affected products
1Patches
15 files changed · +809 −29
packages/unhead/src/plugins/safe.ts+134 −22 modified@@ -7,15 +7,108 @@ const WhitelistAttributes = { htmlAttrs: new Set(['class', 'style', 'lang', 'dir'] satisfies (keyof RawInput<'htmlAttrs'>)[]), bodyAttrs: new Set(['class', 'style'] satisfies (keyof RawInput<'bodyAttrs'>)[]), meta: new Set(['name', 'property', 'charset', 'content', 'media'] satisfies (keyof RawInput<'meta'>)[]), - noscript: new Set(['textContent'] satisfies (Partial<keyof RawInput<'noscript'>> | 'textContent')[]), - style: new Set(['media', 'textContent', 'nonce', 'title', 'blocking'] satisfies (Partial<keyof RawInput<'style'>> | 'textContent')[]), + noscript: new Set([] satisfies (Partial<keyof RawInput<'noscript'>> | 'textContent')[]), + style: new Set(['media', 'nonce', 'title', 'blocking'] satisfies (Partial<keyof RawInput<'style'>>)[]), script: new Set(['type', 'textContent', 'nonce', 'blocking'] satisfies (Partial<keyof RawInput<'script'>> | 'textContent')[]), link: new Set(['color', 'crossorigin', 'fetchpriority', 'href', 'hreflang', 'imagesrcset', 'imagesizes', 'integrity', 'media', 'referrerpolicy', 'rel', 'sizes', 'type'] satisfies (keyof RawInput<'link'>)[]), } as const -function acceptDataAttrs(value: Record<string, string>) { +const BlockedLinkRels = new Set(['canonical', 'modulepreload', 'prerender', 'preload', 'prefetch', 'dns-prefetch', 'preconnect', 'manifest', 'pingback']) + +const SafeAttrName = /^[a-z][a-z0-9\-]*[a-z0-9]$/i + +const HtmlEntityHex = /&#x([0-9a-f]{1,6});?/gi +const HtmlEntityDec = /&#(\d{1,7});?/g +const HtmlEntityNamed = /&(tab|newline|colon|semi|lpar|rpar|sol|bsol|comma|period|excl|num|dollar|percnt|amp|apos|ast|plus|lt|gt|equals|quest|at|lsqb|rsqb|lcub|rcub|vert|hat|grave|tilde|nbsp);?/gi +// eslint-disable-next-line no-control-regex +const ControlChars = /[\x00-\x20]+/g + +const NamedEntityMap: Record<string, string> = { + tab: '\t', + newline: '\n', + colon: ':', + semi: ';', + lpar: '(', + rpar: ')', + sol: '/', + bsol: '\\', + comma: ',', + period: '.', + excl: '!', + num: '#', + dollar: '$', + percnt: '%', + amp: '&', + apos: '\'', + ast: '*', + plus: '+', + lt: '<', + gt: '>', + equals: '=', + quest: '?', + at: '@', + lsqb: '[', + rsqb: ']', + lcub: '{', + rcub: '}', + vert: '|', + hat: '^', + grave: '`', + tilde: '~', + nbsp: '\u00A0', +} + +// Decode HTML entities (numeric and named) that browsers decode in attribute values before URL processing +function safeFromCodePoint(codePoint: number): string { + if (codePoint > 0x10FFFF || codePoint < 0 || Number.isNaN(codePoint)) + return '' + return String.fromCodePoint(codePoint) +} + +function decodeHtmlEntities(str: string): string { + return str.replace(HtmlEntityHex, (_, hex) => safeFromCodePoint(Number.parseInt(hex, 16))) + .replace(HtmlEntityDec, (_, dec) => safeFromCodePoint(Number(dec))) + .replace(HtmlEntityNamed, (_, name) => NamedEntityMap[name.toLowerCase()] || '') +} + +// Strip ASCII control chars (0x00-0x1F), whitespace, and percent-decode, then check for dangerous schemes +function hasDangerousProtocol(url: string): boolean { + // decode HTML entities first (browsers decode these in attribute values before URL processing) + const entityDecoded = decodeHtmlEntities(url) + // strip control chars, tabs, newlines, spaces that browsers strip during URL parsing + const cleaned = entityDecoded.replace(ControlChars, '') + // percent-decode to catch java%73cript: etc + let decoded: string + try { + decoded = decodeURIComponent(cleaned) + } + catch { + decoded = cleaned + } + // strip control chars again after decoding (encoded control chars like %09) + const sanitized = decoded.replace(ControlChars, '') + const lower = sanitized.toLowerCase() + return lower.startsWith('javascript:') || lower.startsWith('data:') || lower.startsWith('vbscript:') +} + +function stripProtoKeys(obj: any): any { + if (Array.isArray(obj)) + return obj.map(stripProtoKeys) + if (obj && typeof obj === 'object') { + const clean: Record<string, any> = {} + for (const key of Object.keys(obj)) { + if (key === '__proto__' || key === 'constructor') + continue + clean[key] = stripProtoKeys(obj[key]) + } + return clean + } + return obj +} + +function acceptDataAttrs(value: Record<string, string>, allowId = true) { return Object.fromEntries( - Object.entries(value || {}).filter(([key]) => key === 'id' || key.startsWith('data-')), + Object.entries(value || {}).filter(([key]) => ((allowId && key === 'id') || key.startsWith('data-')) && SafeAttrName.test(key)), ) } @@ -24,8 +117,10 @@ function makeTagSafe(tag: HeadTag): HeadSafe | false { const { tag: type, props: prev } = tag switch (type) { - // always safe + // title: textContent is escaped in rendering (tagToString), no props needed case 'title': + break + // virtual tags, not rendered to HTML — but sanitize to prevent injection if rendered case 'titleTemplate': case 'templateParams': next = prev @@ -37,7 +132,11 @@ function makeTagSafe(tag: HeadTag): HeadSafe | false { next[attr] = prev[attr] } }) - break + // don't allow id on html/body (DOM clobbering) + delete tag.innerHTML + delete tag.textContent + tag.props = { ...acceptDataAttrs(prev, false), ...next } + return !Object.keys(tag.props).length ? false : tag case 'style': next = acceptDataAttrs(prev) WhitelistAttributes.style.forEach((key) => { @@ -54,19 +153,24 @@ function makeTagSafe(tag: HeadTag): HeadSafe | false { } }) break - // link tags we don't allow stylesheets, scripts, preloading, prerendering, prefetching, etc + // link tags we block preloading, prerendering, prefetching, dns-prefetch, preconnect, manifest, etc case 'link': WhitelistAttributes.link.forEach((key) => { const val = prev[key] if (!val) { return } // block bad rel types - if (key === 'rel' && (val === 'canonical' || val === 'modulepreload' || val === 'prerender' || val === 'preload' || val === 'prefetch')) { + if (key === 'rel' && (typeof val !== 'string' || BlockedLinkRels.has(val.toLowerCase()))) { return } - if (key === 'href') { - if (val.includes('javascript:') || val.includes('data:')) { + if (key === 'href' || key === 'imagesrcset') { + if (typeof val !== 'string') { + return + } + // for imagesrcset, validate each comma-separated URL entry + const urls = key === 'imagesrcset' ? val.split(',').map(s => s.trim()) : [val] + if (urls.some(u => hasDangerousProtocol(u))) { return } next[key] = val @@ -88,30 +192,38 @@ function makeTagSafe(tag: HeadTag): HeadSafe | false { break // we only allow JSON in scripts case 'script': - if (!tag.textContent || !prev.type?.endsWith('json')) { + if (!tag.textContent || typeof prev.type !== 'string' || !prev.type.endsWith('json')) { + return false + } + // sanitize textContent via JSON round-trip, stripping proto keys + try { + const jsonVal = typeof tag.textContent === 'string' ? JSON.parse(tag.textContent) : tag.textContent + tag.textContent = JSON.stringify(stripProtoKeys(jsonVal), null, 0) + } + catch { return false } WhitelistAttributes.script.forEach((s) => { - if (prev[s] === 'textContent') { - try { - const jsonVal = typeof prev[s] === 'string' ? JSON.parse(prev[s]) : prev[s] - next[s] = JSON.stringify(jsonVal, null, 0) - } - catch { - } - } - else if (prev[s]) { + if (s !== 'textContent' && prev[s]) { next[s] = prev[s] } }) break } - if (!Object.keys(next).length && !tag.tag.endsWith('Attrs')) { - return false + // never allow innerHTML in safe mode + delete tag.innerHTML + // only allow textContent for title (escaped in rendering) and script (JSON-sanitized above) + if (type !== 'title' && type !== 'script') { + delete tag.textContent } tag.props = { ...acceptDataAttrs(prev), ...next } + + if (!Object.keys(tag.props).length && !tag.tag.endsWith('Attrs') && !tag.textContent) { + return false + } + return tag }
packages/unhead/test/unit/client/useHeadSafe.test.ts+9 −2 modified@@ -27,6 +27,11 @@ describe('dom useHeadSafe', () => { { textContent: 'body { background: url("javascript:alert(1)") }', }, + { + // @ts-expect-error intentionally invalid + 'innerHTML': 'body { background: url("javascript:alert(1)") }', + 'data-foo': 'bar', + }, ], script: [ { @@ -44,10 +49,12 @@ describe('dom useHeadSafe', () => { ], }) - expect(await useDelayedSerializedDom()).toMatchInlineSnapshot(` + const dom = await useDelayedSerializedDom() + expect(dom).not.toContain('javascript:alert') + expect(dom).toMatchInlineSnapshot(` "<!DOCTYPE html><html lang="en" dir="ltr"><head> - <meta charset="utf-8"><link href="https://cdn.example.com/style.css" rel="stylesheet"><link href="https://cdn.example.com/favicon.ico" rel="icon" type="image/x-icon"><script type="application/json">{"value":"alert(1)"}</script></head> + <meta charset="utf-8"><link href="https://cdn.example.com/style.css" rel="stylesheet"><style data-foo="bar"></style><link href="https://cdn.example.com/favicon.ico" rel="icon" type="image/x-icon"><script type="application/json">{"value":"alert(1)"}</script></head> <body class="dark"> <div>
packages/unhead/test/unit/server/useHeadSafe-edge-cases.test.ts+502 −0 added@@ -0,0 +1,502 @@ +import { describe, expect, it } from 'vitest' +import { useHeadSafe } from '../../../src' +import { renderSSRHead } from '../../../src/server' +import { createServerHeadWithContext } from '../../util' + +// Note: SafeInputPlugin must be registered via useHeadSafe() — calling +// head.push() with _safe:true alone does NOT activate the safe plugin. + +async function safeRender(input: any) { + const head = createServerHeadWithContext() + useHeadSafe(head, input) + return renderSSRHead(head) +} + +describe('useHeadSafe edge cases', () => { + // ─── 1. Attribute Name Injection ─────────────────────────────────── + describe('attribute name injection', () => { + const maliciousKeys = [ + ['tab in key', 'data-x\tonload=alert(1)'], + ['newline in key', 'data-x\nonload=alert(1)'], + ['slash in key', 'data-x/onload=alert(1)'], + ['angle bracket', 'data-x>onload=alert(1)'], + ['double quote', 'data-x"onload=alert(1)'], + ['single quote', 'data-x\'onload=alert(1)'], + ['null byte', 'data-\x00foo'], + ['empty after prefix', 'data-'], + ['unicode emoji', 'data-\u{1F389}'], + ['equals sign', 'data-x=y'], + ['space at end', 'data-x '], + ['backtick', 'data-x`'], + ] as const + + for (const [label, key] of maliciousKeys) { + it(`blocks ${label}: ${JSON.stringify(key)}`, async () => { + const ctx = await safeRender({ + meta: [{ name: 'test', content: 'safe', [key]: 'injected' }], + }) + expect(ctx.headTags).not.toContain('injected') + expect(ctx.headTags).not.toContain('onload') + expect(ctx.headTags).not.toContain('alert') + }) + } + + it('allows valid data- attributes', async () => { + const ctx = await safeRender({ + meta: [{ 'name': 'test', 'content': 'safe', 'data-valid': 'yes', 'data-also-valid-123': 'yes' }], + }) + expect(ctx.headTags).toContain('data-valid="yes"') + expect(ctx.headTags).toContain('data-also-valid-123="yes"') + }) + }) + + // ─── 2. Protocol Check Bypass ────────────────────────────────────── + describe('protocol check bypass', () => { + const maliciousHrefs = [ + ['mixed case javascript', 'jAvAsCrIpT:alert(1)'], + ['all caps JAVASCRIPT', 'JAVASCRIPT:alert(1)'], + ['mixed case data', 'DaTa:text/html,<script>alert(1)</script>'], + ['leading space + javascript', ' javascript:alert(1)'], + ['leading tab + javascript', '\tjavascript:alert(1)'], + ['null prefix + javascript', '\x00javascript:alert(1)'], + ['data URI with HTML', 'data:text/html,<script>alert(1)</script>'], + ['data URI with base64', 'DATA:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=='], + ['data URI with CSS', 'Data:text/css,body{display:none}'], + // percent-encoded protocol bypass + ['percent-encoded javascript (java%73cript)', 'java%73cript:alert(1)'], + ['percent-encoded data (da%74a)', 'da%74a:text/html,<script>alert(1)</script>'], + ['percent-encoded vbscript', 'v%62script:alert(1)'], + // whitespace-in-protocol bypass (browsers strip tabs/newlines inside scheme) + ['tab inside scheme', 'java\tscript:alert(1)'], + ['newline inside scheme', 'java\nscript:alert(1)'], + ['CR inside scheme', 'java\rscript:alert(1)'], + ['mixed whitespace in scheme', 'j\ta\nv\ra\tscript:alert(1)'], + // vbscript protocol (IE11/Edge IE-mode) + ['vbscript', 'vbscript:msgbox(1)'], + ['VBSCRIPT caps', 'VBSCRIPT:MsgBox(1)'], + ['vbscript with space prefix', ' vbscript:alert(1)'], + // HTML entity encoded protocols (browsers decode entities in attribute values before URL processing) + ['decimal entity javascript', 'javascript:alert(1)'], + ['hex entity javascript', 'javascript:alert(1)'], + ['decimal entity data', 'data:text/html,<script>alert(1)</script>'], + ['hex entity data', 'data:text/html,<script>alert(1)</script>'], + ['mixed entity + percent encoding', 'java%73cript:alert(1)'], + ['entity without semicolon', 'javascript:alert(1)'], + ['full entity-encoded javascript:', 'javascript:alert(1)'], + ] as const + + for (const [label, href] of maliciousHrefs) { + it(`blocks link href: ${label}`, async () => { + const ctx = await safeRender({ + link: [{ rel: 'stylesheet', href }], + }) + expect(ctx.headTags).not.toContain('href=') + }) + } + + for (const [label, srcset] of maliciousHrefs) { + it(`blocks link imagesrcset: ${label}`, async () => { + const ctx = await safeRender({ + link: [{ rel: 'icon', href: '/safe.png', imagesrcset: srcset }], + }) + expect(ctx.headTags).not.toContain('imagesrcset=') + }) + } + + it('blocks malicious URL in multi-value imagesrcset', async () => { + const ctx = await safeRender({ + link: [{ rel: 'icon', href: '/safe.png', imagesrcset: '/safe.png 1x, javascript:alert(1) 2x' }], + }) + expect(ctx.headTags).not.toContain('imagesrcset=') + }) + + it('blocks percent-encoded URL in multi-value imagesrcset', async () => { + const ctx = await safeRender({ + link: [{ rel: 'icon', href: '/safe.png', imagesrcset: '/safe.png 1x, java%73cript:alert(1) 2x' }], + }) + expect(ctx.headTags).not.toContain('imagesrcset=') + }) + + it('allows safe multi-value imagesrcset', async () => { + const ctx = await safeRender({ + link: [{ rel: 'icon', href: '/safe.png', imagesrcset: '/small.png 1x, /large.png 2x' }], + }) + expect(ctx.headTags).toContain('imagesrcset=') + }) + }) + + // ─── 3. Type Coercion & Prototype Pollution ──────────────────────── + describe('type coercion and prototype pollution', () => { + it('crashes on href with toString override (normalization DoS, not XSS)', async () => { + // Objects with toString overrides break during normalization (String() call) + // This is a DoS vector, not XSS — the render crashes instead of producing output + await expect(safeRender({ + link: [{ rel: 'icon', href: { toString: () => 'javascript:alert(1)' } as any }], + })).rejects.toThrow() + }) + + it('crashes on rel with toString override (normalization DoS, not XSS)', async () => { + await expect(safeRender({ + link: [{ rel: { toString: () => 'icon' } as any, href: '/safe.css' }], + })).rejects.toThrow() + }) + + it('ignores __proto__ pollution attempts', async () => { + const malicious = JSON.parse('{"__proto__":{"onload":"alert(1)"}}') + const ctx = await safeRender({ + meta: [{ name: 'test', content: 'safe', ...malicious }], + }) + expect(ctx.headTags).not.toContain('onload') + }) + + it('ignores constructor pollution attempts', async () => { + const ctx = await safeRender({ + meta: [{ name: 'test', content: 'safe', constructor: { prototype: { onload: 'alert(1)' } } }], + }) + expect(ctx.headTags).not.toContain('onload') + }) + }) + + // ─── 4. Script textContent JSON Bypass ───────────────────────────── + describe('script textContent JSON bypass', () => { + it('escapes closing script tags in JSON values', async () => { + const ctx = await safeRender({ + script: [{ + type: 'application/ld+json', + textContent: '{"x":"</script><img src=x onerror=alert(1)>"}', + }], + }) + // The closing </script tag should be escaped by tagToString + expect(ctx.headTags).not.toContain('</script><img') + expect(ctx.headTags).toContain('application/ld+json') + }) + + it('allows non-standard json type suffix', async () => { + const ctx = await safeRender({ + script: [{ type: 'ld+json', textContent: '{"safe":true}' }], + }) + // endsWith('json') matches 'ld+json' + expect(ctx.headTags).toContain('ld+json') + }) + + it('blocks falsy textContent', async () => { + const ctx = await safeRender({ + script: [{ type: 'application/ld+json', textContent: '' }], + }) + expect(ctx.headTags).toBe('') + }) + + it('blocks invalid JSON textContent', async () => { + const ctx = await safeRender({ + script: [{ type: 'application/ld+json', textContent: '{not valid json' }], + }) + expect(ctx.headTags).toBe('') + }) + + it('blocks text/javascript', async () => { + const ctx = await safeRender({ script: [{ type: 'text/javascript', textContent: 'alert(1)' }] }) + expect(ctx.headTags).toBe('') + }) + + it('blocks module type', async () => { + const ctx = await safeRender({ script: [{ type: 'module', textContent: 'alert(1)' }] }) + expect(ctx.headTags).toBe('') + }) + + it('blocks application/javascript', async () => { + const ctx = await safeRender({ script: [{ type: 'application/javascript', textContent: 'alert(1)' }] }) + expect(ctx.headTags).toBe('') + }) + + it('blocks empty type', async () => { + const ctx = await safeRender({ script: [{ type: '', textContent: 'alert(1)' }] }) + expect(ctx.headTags).toBe('') + }) + + it('preserves HTML in JSON values (safe in ld+json context)', async () => { + const ctx = await safeRender({ + script: [{ + type: 'application/ld+json', + textContent: '{"name":"<img onerror=alert(1)>"}', + }], + }) + // ld+json content is NOT parsed as HTML by browsers, so HTML in values is safe + // The important thing is that </script> is escaped (tested above) + expect(ctx.headTags).toContain('application/ld+json') + expect(ctx.headTags).toContain('"name":"<img onerror=alert(1)>"') + }) + }) + + // ─── 5. innerHTML/textContent Resurrection ───────────────────────── + describe('content resurrection', () => { + it('blocks style innerHTML', async () => { + const ctx = await safeRender({ + style: [{ 'innerHTML': 'body{display:none}', 'data-x': '1' } as any], + }) + expect(ctx.headTags).not.toContain('display:none') + }) + + it('blocks style textContent', async () => { + const ctx = await safeRender({ + style: [{ textContent: 'body{display:none}' }], + }) + expect(ctx.headTags).not.toContain('display:none') + }) + + it('blocks noscript innerHTML', async () => { + const ctx = await safeRender({ + noscript: [{ innerHTML: '<img src=x onerror=alert(1)>' } as any], + }) + expect(ctx.headTags).not.toContain('onerror') + }) + + it('blocks noscript textContent', async () => { + const ctx = await safeRender({ + noscript: [{ textContent: '<img src=x onerror=alert(1)>' }], + }) + expect(ctx.headTags).not.toContain('onerror') + }) + + it('blocks script innerHTML (non-json)', async () => { + const ctx = await safeRender({ + script: [{ innerHTML: 'alert(1)' } as any], + }) + expect(ctx.headTags).not.toContain('alert') + }) + + it('blocks script innerHTML even with json type', async () => { + const ctx = await safeRender({ + script: [{ type: 'application/ld+json', innerHTML: '{"safe":true}' } as any], + }) + // innerHTML should be stripped; only textContent is used for scripts + // The script should still render if textContent was also set, but innerHTML alone shouldn't work + expect(ctx.headTags).not.toContain('innerHTML') + }) + }) + + // ─── 6. Link rel Blocklist Gaps ──────────────────────────────────── + describe('link rel security', () => { + const blockedRels = ['canonical', 'modulepreload', 'prerender', 'preload', 'prefetch', 'dns-prefetch', 'preconnect', 'manifest', 'pingback'] + + for (const rel of blockedRels) { + it(`blocks rel="${rel}"`, async () => { + const ctx = await safeRender({ + link: [{ rel, href: 'https://example.com' }], + }) + expect(ctx.headTags).toBe('') + }) + } + + it('allows safe rel types', async () => { + for (const rel of ['icon', 'stylesheet', 'alternate']) { + const ctx = await safeRender({ + link: [{ rel, href: 'https://example.com/resource' }], + }) + expect(ctx.headTags).toContain(`rel="${rel}"`) + } + }) + + it('blocks link without rel', async () => { + const ctx = await safeRender({ + link: [{ href: 'https://example.com' } as any], + }) + expect(ctx.headTags).toBe('') + }) + + it('blocks link without href or imagesrcset', async () => { + const ctx = await safeRender({ + link: [{ rel: 'icon' }], + }) + expect(ctx.headTags).toBe('') + }) + }) + + // ─── 7. Title Safety ────────────────────────────────────────────── + describe('title safety', () => { + it('renders title with escaped HTML', async () => { + const ctx = await safeRender({ + title: '<script>alert(1)</script>', + }) + expect(ctx.headTags).not.toContain('<script>') + expect(ctx.headTags).toContain('<script>') + }) + + it('strips event handlers from title props', async () => { + const ctx = await safeRender({ + title: 'Safe Title', + }) + expect(ctx.headTags).toContain('<title>Safe Title</title>') + expect(ctx.headTags).not.toContain('onload') + }) + + it('title does not pass arbitrary props', async () => { + // title input as object (if someone bypasses types) — must use useHeadSafe to register plugin + const ctx = await safeRender({ + title: { textContent: 'My Title', onload: 'alert(1)' } as any, + }) + expect(ctx.headTags).not.toContain('onload') + expect(ctx.headTags).toContain('My Title') + }) + }) + + // ─── 8. Meta Safety ─────────────────────────────────────────────── + describe('meta safety', () => { + it('blocks http-equiv (CSP override, refresh)', async () => { + const ctx = await safeRender({ + meta: [ + { 'http-equiv': 'refresh', 'content': '0;url=javascript:alert(1)' } as any, + { 'http-equiv': 'Content-Security-Policy', 'content': 'script-src \'unsafe-inline\'' } as any, + ], + }) + expect(ctx.headTags).not.toContain('http-equiv') + expect(ctx.headTags).not.toContain('refresh') + expect(ctx.headTags).not.toContain('Content-Security-Policy') + }) + + it('escapes quotes in attribute values', async () => { + const ctx = await safeRender({ + meta: [{ name: 'description', content: 'He said "hello" & <goodbye>' }], + }) + expect(ctx.headTags).toContain('"') + // < and > are not escaped in attribute values (safe inside double quotes) + expect(ctx.headTags).toContain('content="He said "hello" & <goodbye>"') + }) + }) + + // ─── 9. htmlAttrs/bodyAttrs Safety ──────────────────────────────── + describe('htmlAttrs and bodyAttrs safety', () => { + it('blocks event handlers on htmlAttrs', async () => { + const ctx = await safeRender({ + htmlAttrs: { onload: 'alert(1)', class: 'safe' } as any, + }) + expect(ctx.htmlAttrs).not.toContain('onload') + expect(ctx.htmlAttrs).toContain('class') + }) + + it('blocks event handlers on bodyAttrs', async () => { + const ctx = await safeRender({ + bodyAttrs: { onresize: 'alert(1)', class: 'safe' } as any, + }) + expect(ctx.bodyAttrs).not.toContain('onresize') + expect(ctx.bodyAttrs).toContain('class') + }) + + it('blocks non-whitelisted attrs', async () => { + const ctx = await safeRender({ + htmlAttrs: { id: 'x', tabindex: '0', role: 'main' } as any, + }) + // Only class, style, lang, dir are whitelisted for htmlAttrs + expect(ctx.htmlAttrs).not.toContain('tabindex') + expect(ctx.htmlAttrs).not.toContain('role') + }) + }) + + // ─── 10. Unknown Tag Types ──────────────────────────────────────── + describe('unknown tag types', () => { + it('blocks tags not in the switch statement', async () => { + // base tag should be blocked since it's not in the safe switch + const ctx = await safeRender({ + base: { href: 'https://evil.com/' }, + } as any) + expect(ctx.headTags).not.toContain('base') + expect(ctx.headTags).not.toContain('evil.com') + }) + }) + + // ─── 11. DOM Clobbering via id ────────────────────────────────── + describe('dom clobbering prevention', () => { + it('blocks id on htmlAttrs', async () => { + const ctx = await safeRender({ + htmlAttrs: { id: 'defaultView', class: 'safe' } as any, + }) + expect(ctx.htmlAttrs).not.toContain('id=') + expect(ctx.htmlAttrs).toContain('class="safe"') + }) + + it('blocks id on bodyAttrs', async () => { + const ctx = await safeRender({ + bodyAttrs: { id: 'forms', class: 'safe' } as any, + }) + expect(ctx.bodyAttrs).not.toContain('id=') + expect(ctx.bodyAttrs).toContain('class="safe"') + }) + + it('allows id on element tags (meta, link, etc)', async () => { + const ctx = await safeRender({ + meta: [{ id: 'my-meta', name: 'test', content: 'safe' }], + }) + expect(ctx.headTags).toContain('id="my-meta"') + }) + }) + + // ─── 12. Script JSON __proto__ sanitization ──────────────────────── + describe('script JSON __proto__ sanitization', () => { + it('strips __proto__ keys from JSON textContent via round-trip', async () => { + const ctx = await safeRender({ + script: [{ + type: 'application/ld+json', + textContent: '{"__proto__":{"polluted":true},"safe":"value"}', + }], + }) + expect(ctx.headTags).not.toContain('__proto__') + expect(ctx.headTags).not.toContain('polluted') + expect(ctx.headTags).toContain('"safe":"value"') + }) + }) + + // ─── 13. titleTemplate textContent stripping ─────────────────────── + describe('titleTemplate safety', () => { + it('strips textContent from titleTemplate', async () => { + const ctx = await safeRender({ + titleTemplate: '<script>alert(1)</script> %s' as any, + }) + // titleTemplate textContent should be stripped to prevent injection + expect(ctx.headTags).not.toContain('<script>alert(1)</script>') + }) + }) + + // ─── 14. Combined Attack Vectors ────────────────────────────────── + describe('combined attacks', () => { + it('blocks data- attr injection + legitimate attrs', async () => { + const ctx = await safeRender({ + link: [{ + 'rel': 'stylesheet', + 'href': '/safe.css', + 'data-x onclick=alert(1) y': 'z', + 'data-safe': 'ok', + }], + }) + expect(ctx.headTags).not.toContain('onclick') + expect(ctx.headTags).toContain('data-safe="ok"') + expect(ctx.headTags).toContain('href="/safe.css"') + }) + + it('blocks multiple attack vectors in one input', async () => { + const ctx = await safeRender({ + meta: [ + { 'http-equiv': 'refresh', 'content': '0;url=javascript:alert(1)', 'data-x onload=alert(1)': 'y' } as any, + ], + link: [ + { 'rel': 'preload', 'href': 'JAVASCRIPT:alert(1)', 'data-x onclick=alert(1)': 'y' } as any, + ], + script: [ + { src: 'https://evil.com/script.js', onload: 'alert(1)' } as any, + ], + style: [ + { textContent: 'body{display:none}', innerHTML: '<script>alert(1)</script>' } as any, + ], + noscript: [ + { textContent: '<img src=x onerror=alert(1)>' }, + ], + }) + // http-equiv is blocked, so meta with javascript: in content is safe + // (content is just data, not a URL context) + expect(ctx.headTags).not.toContain('onclick') + expect(ctx.headTags).not.toContain('onload') + expect(ctx.headTags).not.toContain('onerror') + expect(ctx.headTags).not.toContain('http-equiv') + expect(ctx.headTags).not.toContain('display:none') + expect(ctx.headTags).not.toContain('evil.com') + }) + }) +})
packages/unhead/test/unit/server/useHeadSafe.test.ts+157 −0 modified@@ -77,6 +77,163 @@ describe('dom useHeadSafe', () => { `) }) + it('blocks XSS via data-* attribute name injection', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + link: [{ + 'rel': 'stylesheet', + 'href': '/valid-stylesheet.css', + 'data-x onload=alert(1) y': 'z', + }], + }) + + const ctx = await renderSSRHead(head) + // The injected attribute key with spaces should be stripped + expect(ctx.headTags).not.toContain('onload') + expect(ctx.headTags).toContain('rel="stylesheet"') + expect(ctx.headTags).toContain('href="/valid-stylesheet.css"') + }) + + it('blocks case-varied javascript: and data: URIs in link href', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + link: [ + { rel: 'stylesheet', href: 'DATA:text/css,body{display:none}' }, + { rel: 'stylesheet', href: 'JAVASCRIPT:alert(1)' }, + { rel: 'stylesheet', href: 'Javascript:alert(1)' }, + { rel: 'stylesheet', href: 'Data:text/css,*{color:red}' }, + ], + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).toBe('') + }) + + it('strips noscript textContent to prevent HTML injection', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + noscript: [{ + textContent: '<img src=x onerror=alert(1)>', + }], + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).toBe('') + }) + + it('sanitizes script textContent through JSON parse/stringify', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + script: [{ + type: 'application/ld+json', + textContent: '{"@type": "Organization", "name": "Test"}', + }], + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).toContain('application/ld+json') + expect(ctx.headTags).toContain('"@type":"Organization"') + }) + + it('blocks dangerous URIs in imagesrcset', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + link: [ + { rel: 'icon', href: '/ok.png', imagesrcset: 'data:image/svg+xml,<svg onload=alert(1)>' }, + ], + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).not.toContain('data:') + }) + + it('title renders safely', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + title: 'My Safe Page', + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).toContain('<title>My Safe Page</title>') + }) + + it('title strips event handler props', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + title: 'My Page', + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).not.toContain('onload') + expect(ctx.headTags).toContain('<title>My Page</title>') + }) + + it('blocks style textContent (CSS injection)', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + style: [{ + textContent: 'body{display:none}input[value^="a"]{background:url(https://evil.com/a)}', + }], + }) + + const ctx = await renderSSRHead(head) + // style textContent should be stripped in safe mode + expect(ctx.headTags).not.toContain('display:none') + expect(ctx.headTags).not.toContain('evil.com') + }) + + it('blocks style innerHTML (CSS injection)', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + style: [{ + // @ts-expect-error intentionally invalid + 'innerHTML': 'body { background: url("javascript:alert(1)") }', + 'data-foo': 'bar', + }], + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).not.toContain('javascript') + expect(ctx.headTags).not.toContain('background') + }) + + it('blocks script with invalid JSON textContent', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + script: [{ + type: 'application/ld+json', + textContent: '</script><script>alert(1)</script>', + }], + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).toBe('') + }) + + it('blocks script with non-json type', async () => { + const head = createServerHeadWithContext() + + useHeadSafe(head, { + script: [{ + type: 'text/javascript', + textContent: 'alert(1)', + }], + }) + + const ctx = await renderSSRHead(head) + expect(ctx.headTags).toBe('') + }) + it('meta charset is actually safe', async () => { const head = createServerHeadWithContext()
packages/vue/test/unit/dom/useHeadSafe.test.ts+7 −5 modified@@ -84,11 +84,13 @@ describe('vue dom useHeadSafe', () => { await renderDOMHead(head, { document: dom.window.document }) - expect(dom.serialize()).toMatchInlineSnapshot(` - "<html lang="en" dir="ltr"><head> + // Wait for debounced render to complete + await new Promise(resolve => setTimeout(resolve, 50)) - <meta charset="utf-8"><link data-bar="foo" href="https://cdn.example.com/style.css" rel="stylesheet"><style data-foo="bar">body { background: url("javascript:alert(1)") }</style><link href="https://cdn.example.com/favicon.ico" rel="icon" type="image/x-icon"><link data-bar="foo" href="/valid.png" rel="icon"><link href="alert(1)" rel="icon"></head> - <body class="dark" data-bar="foo"><div id="app" data-v-app=""><div>hello world</div></div></body></html>" - `) + const serialized = dom.serialize() + // Style innerHTML should be stripped in safe mode + expect(serialized).not.toContain('javascript:alert') + // onresize on body should be stripped + expect(serialized).not.toContain('onresize') }) })
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-g5xx-pwrp-g3fvghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-31860ghsaADVISORY
- github.com/unjs/unhead/commit/9ecc4f9568b0e23938f36d4b23fcfa4a18a89045ghsaWEB
- github.com/unjs/unhead/releases/tag/v2.1.11ghsaWEB
- github.com/unjs/unhead/security/advisories/GHSA-g5xx-pwrp-g3fvghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.