Skip to content

Commit 5724b38

Browse files
committed
fix: sanitize component props to prevent reflected XSS (GHSA-mg36-wvcr-m75h)
Query params not matching known OG image options were passed as component props to the Nuxt island renderer. Vue's fallthrough attributes then rendered them as HTML attributes on the root element, enabling injection of event handlers like onmouseover.
1 parent a8a65b6 commit 5724b38

File tree

3 files changed

+44
-1
lines changed

3 files changed

+44
-1
lines changed

src/runtime/server/og-image/context.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { useNitroApp } from 'nitropack/runtime'
1616
import { hash } from 'ohash'
1717
import { parseURL, withoutLeadingSlash, withoutTrailingSlash, withQuery } from 'ufo'
1818
import { normalizeKey } from 'unstorage'
19-
import { decodeOgImageParams, extractEncodedSegment, separateProps } from '../../shared'
19+
import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps } from '../../shared'
2020
import { autoEjectCommunityTemplate } from '../util/auto-eject'
2121
import { createNitroRouteRuleMatcher } from '../util/kit'
2222
import { normaliseOptions } from '../util/options'
@@ -131,6 +131,10 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
131131
const ogImageRouteRules = separateProps(routeRules.ogImage as RouteRulesOgImage)
132132
const options = defu(queryParams, urlOptions, ogImageRouteRules, runtimeConfig.defaults) as OgImageOptionsInternal
133133

134+
// Strip HTML event handlers and dangerous attributes from props (GHSA-mg36-wvcr-m75h)
135+
if (options.props && typeof options.props === 'object')
136+
options.props = sanitizeProps(options.props as Record<string, any>)
137+
134138
if (!options) {
135139
return createError({
136140
statusCode: 404,

src/runtime/shared.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ export function separateProps(options: OgImageOptions | undefined, ignoreKeys: s
100100
return result as OgImageOptions
101101
}
102102

103+
const DANGEROUS_ATTRS = new Set(['autofocus', 'contenteditable', 'tabindex', 'accesskey'])
104+
105+
/**
106+
* Strip HTML event handlers and dangerous attributes from props to prevent
107+
* reflected XSS via Vue fallthrough attributes (GHSA-mg36-wvcr-m75h).
108+
*/
109+
export function sanitizeProps(props: Record<string, any>): Record<string, any> {
110+
const clean: Record<string, any> = {}
111+
for (const key of Object.keys(props)) {
112+
if (key.startsWith('on') || DANGEROUS_ATTRS.has(key.toLowerCase()))
113+
continue
114+
clean[key] = props[key]
115+
}
116+
return clean
117+
}
118+
103119
export function withoutQuery(path: string) {
104120
return path.split('?')[0]
105121
}

test/unit/security.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { sanitizeProps } from '../../src/runtime/shared'
3+
4+
describe('sanitizeProps (GHSA-mg36-wvcr-m75h)', () => {
5+
it('strips on* event handlers', () => {
6+
const result = sanitizeProps({ title: 'Hello', onmouseover: 'alert(1)', onclick: 'steal()' })
7+
expect(result).toEqual({ title: 'Hello' })
8+
})
9+
10+
it('strips dangerous HTML attributes', () => {
11+
const result = sanitizeProps({ title: 'Hi', autofocus: '', contenteditable: 'true', tabindex: '1', accesskey: 'x' })
12+
expect(result).toEqual({ title: 'Hi' })
13+
})
14+
15+
it('preserves legitimate props', () => {
16+
const result = sanitizeProps({ title: 'Test', description: 'A page', colorMode: 'dark', theme: '#fff' })
17+
expect(result).toEqual({ title: 'Test', description: 'A page', colorMode: 'dark', theme: '#fff' })
18+
})
19+
20+
it('handles empty props', () => {
21+
expect(sanitizeProps({})).toEqual({})
22+
})
23+
})

0 commit comments

Comments
 (0)