Skip to content

Commit bd05a77

Browse files
authored
fix: whitelist component props to prevent cache key DoS (#544)
1 parent 9902a89 commit bd05a77

File tree

5 files changed

+216
-5
lines changed

5 files changed

+216
-5
lines changed

src/build/props.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Extract prop names from a Vue SFC's `defineProps` declaration.
3+
*
4+
* Uses `@vue/compiler-sfc` (already a dependency) to reliably parse all
5+
* three defineProps syntaxes: TS generics, runtime objects, and arrays.
6+
* The `compileScript` API resolves bindings, so we just filter for "props".
7+
*/
8+
import type { SFCDescriptor } from '@vue/compiler-sfc'
9+
10+
let _parse: typeof import('@vue/compiler-sfc').parse | undefined
11+
let _compileScript: typeof import('@vue/compiler-sfc').compileScript | undefined
12+
13+
export async function loadSfcCompiler() {
14+
if (!_parse) {
15+
const sfc = await import('@vue/compiler-sfc')
16+
_parse = sfc.parse
17+
_compileScript = sfc.compileScript
18+
}
19+
}
20+
21+
export function extractPropNamesFromVue(code: string): string[] {
22+
if (!_parse || !_compileScript)
23+
return []
24+
25+
let descriptor: SFCDescriptor
26+
try {
27+
descriptor = _parse(code).descriptor
28+
}
29+
catch {
30+
return []
31+
}
32+
33+
if (!descriptor.scriptSetup)
34+
return []
35+
36+
try {
37+
const compiled = _compileScript(descriptor, { id: 'prop-extract' })
38+
if (!compiled.bindings)
39+
return []
40+
return Object.entries(compiled.bindings)
41+
.filter(([, type]) => type === 'props')
42+
.map(([name]) => name)
43+
}
44+
catch {
45+
return []
46+
}
47+
}

src/module.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
} from './build/fonts'
3636
import { setupGenerateHandler } from './build/generate'
3737
import { setupPrerenderHandler } from './build/prerender'
38+
import { extractPropNamesFromVue, loadSfcCompiler } from './build/props'
3839
import { TreeShakeComposablesPlugin } from './build/tree-shake-plugin'
3940
import { AssetTransformPlugin } from './build/vite-asset-transform'
4041
import { ComponentImportRewritePlugin } from './build/vite-component-import-rewrite'
@@ -1005,6 +1006,10 @@ export default defineNuxtModule<ModuleOptions>({
10051006
}
10061007
}
10071008

1009+
// Pre-load the SFC compiler so extractPropNamesFromVue works synchronously
1010+
// inside the components:extend hook (which is itself synchronous)
1011+
await loadSfcCompiler()
1012+
10081013
nuxt.hook('components:extend', (components) => {
10091014
allNuxtComponents = components
10101015
ogImageComponentCtx.components = []
@@ -1046,6 +1051,7 @@ export default defineNuxtModule<ModuleOptions>({
10461051
ogImageComponentCtx.detectedRenderers.add(renderer)
10471052
const componentFile = fs.readFileSync(component.filePath, 'utf-8')
10481053
const credits = componentFile.split('\n').find(line => line.startsWith(' * @credits'))?.replace('* @credits', '').trim()
1054+
const propNames = extractPropNamesFromVue(componentFile)
10491055
ogImageComponentCtx.components.push({
10501056
hash: hash(componentFile).replaceAll('_', '-'),
10511057
pascalName: component.pascalName,
@@ -1054,6 +1060,7 @@ export default defineNuxtModule<ModuleOptions>({
10541060
category,
10551061
credits,
10561062
renderer,
1063+
propNames,
10571064
})
10581065
}
10591066
})
@@ -1085,13 +1092,17 @@ export default defineNuxtModule<ModuleOptions>({
10851092
})) {
10861093
return
10871094
}
1095+
const propNames = fs.statSync(filePath).isFile()
1096+
? extractPropNamesFromVue(fs.readFileSync(filePath, 'utf-8'))
1097+
: []
10881098
ogImageComponentCtx.components.push({
10891099
hash: '',
10901100
pascalName,
10911101
kebabName: pascalName.replace(RE_PASCAL_TO_KEBAB, '$1-$2').toLowerCase(),
10921102
path: filePath,
10931103
category: 'community',
10941104
renderer,
1105+
propNames,
10951106
})
10961107
})
10971108
}

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

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +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 { logger } from '../../logger'
1920
import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps } from '../../shared'
2021
import { autoEjectCommunityTemplate } from '../util/auto-eject'
2122
import { createNitroRouteRuleMatcher } from '../util/kit'
@@ -25,17 +26,19 @@ import { getBrowserRenderer, getSatoriRenderer, getTakumiRenderer } from './inst
2526

2627
const RE_HASH_MODE = /^o_([a-z0-9]+)$/i
2728

28-
export function resolvePathCacheKey(e: H3Event, path: string, includeQuery = false) {
29+
export function resolvePathCacheKey(e: H3Event, path: string, resolvedOptions?: Record<string, any>) {
2930
const siteConfig = getSiteConfig(e, {
3031
resolveRefs: true,
3132
})
3233
const basePath = withoutTrailingSlash(withoutLeadingSlash(normalizeKey(path)))
33-
const hashParts = [
34+
const hashParts: any[] = [
3435
basePath,
3536
import.meta.prerender ? '' : siteConfig.url,
3637
]
37-
if (includeQuery)
38-
hashParts.push(hash(getQuery(e)))
38+
// Hash resolved options (not raw query string) so unknown/extra query params
39+
// cannot produce unique cache keys and bypass the cache.
40+
if (resolvedOptions)
41+
hashParts.push(hash(resolvedOptions))
3942
return [
4043
(!basePath || basePath === '/') ? 'index' : basePath,
4144
hash(hashParts),
@@ -168,6 +171,23 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
168171
// Normalise options and get renderer from component metadata
169172
const normalised = normaliseOptions(options)
170173

174+
// Whitelist props: only allow props declared in the component's defineProps.
175+
// Components without defineProps accept no props. Prevents cache key inflation
176+
// from arbitrary query params (DoS vector).
177+
if (normalised.component && normalised.options.props && typeof normalised.options.props === 'object') {
178+
const allowedProps = normalised.component.propNames || []
179+
const allowedSet = new Set(allowedProps)
180+
const raw = normalised.options.props as Record<string, any>
181+
const filtered: Record<string, any> = {}
182+
for (const key of Object.keys(raw)) {
183+
if (allowedSet.has(key))
184+
filtered[key] = raw[key]
185+
else if (import.meta.dev)
186+
logger.warn(`[Nuxt OG Image] Prop "${key}" is not declared by component "${normalised.component.pascalName}" and was dropped. Declared props: ${allowedProps.join(', ')}`)
187+
}
188+
normalised.options.props = filtered
189+
}
190+
171191
// Auto-eject community templates in dev mode (skip devtools requests)
172192
if (normalised.component?.category === 'community')
173193
autoEjectCommunityTemplate(normalised.component, runtimeConfig, { requestPath: e.path })
@@ -177,7 +197,7 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
177197
// so use the options hash directly as cache key to avoid all hash-mode images sharing one cache entry.
178198
// Component hash is appended so template changes invalidate the runtime cache.
179199
const baseCacheKey = normalised.options.cacheKey
180-
|| (hashMatch ? `hash:${hashMatch[1]}` : resolvePathCacheKey(e, basePathWithQuery, runtimeConfig.cacheQueryParams))
200+
|| (hashMatch ? `hash:${hashMatch[1]}` : resolvePathCacheKey(e, basePathWithQuery, normalised.options))
181201
const key = componentHash ? `${baseCacheKey}:${componentHash}` : baseCacheKey
182202

183203
let renderer: ((typeof SatoriRenderer | typeof BrowserRenderer | typeof TakumiRenderer) & { __mock__?: true }) | undefined

src/runtime/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ export interface OgImageComponent {
9696
category: 'app' | 'community' | 'pro'
9797
credits?: string
9898
renderer: RendererType
99+
/** Declared prop names extracted from defineProps at build time (used for prop whitelisting) */
100+
propNames?: string[]
99101
}
100102

101103
export interface ScreenshotOptions {

test/unit/props.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { beforeAll, describe, expect, it } from 'vitest'
2+
import { extractPropNamesFromVue, loadSfcCompiler } from '../../src/build/props'
3+
4+
describe('extractPropNamesFromVue', () => {
5+
beforeAll(async () => {
6+
await loadSfcCompiler()
7+
})
8+
9+
it('extracts from TypeScript generic defineProps', () => {
10+
const code = `
11+
<script setup lang="ts">
12+
withDefaults(defineProps<{
13+
colorMode?: 'dark' | 'light'
14+
title?: string
15+
description?: string
16+
}>(), {
17+
colorMode: 'light',
18+
title: 'title',
19+
})
20+
</script>
21+
<template><div /></template>`
22+
expect(extractPropNamesFromVue(code)).toEqual(['colorMode', 'title', 'description'])
23+
})
24+
25+
it('extracts from TypeScript generic with assignment', () => {
26+
const code = `
27+
<script setup lang="ts">
28+
const props = withDefaults(defineProps<{
29+
title?: string
30+
isPro?: boolean
31+
width?: number
32+
height?: number
33+
}>(), {
34+
title: 'title',
35+
width: 1200,
36+
height: 600,
37+
})
38+
</script>
39+
<template><div /></template>`
40+
expect(extractPropNamesFromVue(code)).toEqual(['title', 'isPro', 'width', 'height'])
41+
})
42+
43+
it('extracts from runtime object syntax', () => {
44+
const code = `
45+
<script setup>
46+
defineProps({
47+
title: String,
48+
count: Number,
49+
active: Boolean,
50+
})
51+
</script>
52+
<template><div /></template>`
53+
expect(extractPropNamesFromVue(code)).toEqual(['title', 'count', 'active'])
54+
})
55+
56+
it('extracts from runtime object with nested type config', () => {
57+
const code = `
58+
<script setup>
59+
defineProps({
60+
title: {
61+
type: String,
62+
default: 'Hello',
63+
},
64+
count: {
65+
type: Number,
66+
required: true,
67+
},
68+
})
69+
</script>
70+
<template><div /></template>`
71+
expect(extractPropNamesFromVue(code)).toEqual(['title', 'count'])
72+
})
73+
74+
it('extracts from array syntax', () => {
75+
const code = `
76+
<script setup>
77+
defineProps(['title', 'description', 'theme'])
78+
</script>
79+
<template><div /></template>`
80+
expect(extractPropNamesFromVue(code)).toEqual(['title', 'description', 'theme'])
81+
})
82+
83+
it('handles nested generic types in TS props', () => {
84+
const code = `
85+
<script setup lang="ts">
86+
defineProps<{
87+
items?: Array<{ id: string, name: string }>
88+
config?: Record<string, boolean>
89+
title?: string
90+
}>()
91+
</script>
92+
<template><div /></template>`
93+
expect(extractPropNamesFromVue(code)).toEqual(['items', 'config', 'title'])
94+
})
95+
96+
it('returns empty for components without script setup', () => {
97+
const code = `
98+
<script>
99+
export default {
100+
props: { title: String }
101+
}
102+
</script>
103+
<template><div /></template>`
104+
expect(extractPropNamesFromVue(code)).toEqual([])
105+
})
106+
107+
it('returns empty for components without defineProps', () => {
108+
const code = `
109+
<script setup lang="ts">
110+
const msg = 'hello'
111+
</script>
112+
<template><div /></template>`
113+
expect(extractPropNamesFromVue(code)).toEqual([])
114+
})
115+
116+
it('returns empty for invalid withDefaults + runtime object (Vue compiler rejects this)', () => {
117+
const code = `
118+
<script setup>
119+
withDefaults(defineProps({
120+
title: String,
121+
color: String,
122+
}), {
123+
title: 'Default',
124+
color: 'blue',
125+
})
126+
</script>
127+
<template><div /></template>`
128+
// withDefaults only works with type-based defineProps; compiler throws
129+
expect(extractPropNamesFromVue(code)).toEqual([])
130+
})
131+
})

0 commit comments

Comments
 (0)