Skip to content

Commit 56231f7

Browse files
committed
fix: whitelist component props to prevent cache key DoS
Extract prop names from defineProps at build time and filter unknown props at runtime. Prevents attackers from inflating cache entries by varying arbitrary query params.
1 parent 3dcf8c1 commit 56231f7

File tree

5 files changed

+262
-0
lines changed

5 files changed

+262
-0
lines changed

src/build/props.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Extract prop names from a Vue SFC's `defineProps` declaration.
3+
*
4+
* Supports all three Vue syntaxes:
5+
* 1. TypeScript generic: `defineProps<{ title: string, desc?: string }>()`
6+
* 2. Runtime object: `defineProps({ title: String, desc: { type: String } })`
7+
* 3. Array shorthand: `defineProps(['title', 'desc'])`
8+
*
9+
* Operates on raw source text (no AST parser needed).
10+
*/
11+
12+
const RE_SCRIPT_SETUP = /<script\s[^>]*setup[^>]*>([\s\S]*?)<\/script>/
13+
14+
// Match top-level keys in a TS interface/object type literal: `{ title: string, desc?: number }`
15+
const RE_TS_PROP_KEY = /(?:^|[;,\n}])\s*(\w+)\s*(?:\?\s*)?:/g
16+
17+
// Match top-level keys in a runtime object: `{ title: String, desc: { type: Number } }`
18+
const RE_RUNTIME_PROP_KEY = /(?:^|[,\n}])\s*(\w+)\s*:/g
19+
20+
// Match array items: `['title', 'desc']` or `["title", "desc"]`
21+
const RE_ARRAY_ITEM = /['"](\w+)['"]/g
22+
23+
function findBalanced(source: string, openChar: string, closeChar: string, startIndex: number): string | null {
24+
let depth = 0
25+
let start = -1
26+
for (let i = startIndex; i < source.length; i++) {
27+
if (source[i] === openChar) {
28+
if (depth === 0)
29+
start = i + 1
30+
depth++
31+
}
32+
else if (source[i] === closeChar) {
33+
depth--
34+
if (depth === 0 && start !== -1)
35+
return source.slice(start, i)
36+
}
37+
}
38+
return null
39+
}
40+
41+
export function extractPropNamesFromVue(code: string): string[] {
42+
const match = RE_SCRIPT_SETUP.exec(code)
43+
if (!match)
44+
return []
45+
46+
const src = match[1]
47+
48+
const dpIndex = src.indexOf('defineProps')
49+
if (dpIndex === -1)
50+
return []
51+
52+
// Check for TypeScript generic syntax: defineProps<...>()
53+
const afterDp = src.slice(dpIndex + 'defineProps'.length).trimStart()
54+
if (afterDp.startsWith('<')) {
55+
const typeBody = findBalanced(src, '<', '>', dpIndex + 'defineProps'.length)
56+
if (typeBody)
57+
return extractTopLevelKeys(typeBody, RE_TS_PROP_KEY)
58+
}
59+
60+
// Check for runtime object or array syntax: defineProps({...}) or defineProps([...])
61+
const parenContent = findBalanced(src, '(', ')', dpIndex + 'defineProps'.length)
62+
if (!parenContent)
63+
return []
64+
65+
const trimmed = parenContent.trim()
66+
67+
// Array syntax: defineProps(['title', 'desc'])
68+
if (trimmed.startsWith('[')) {
69+
const names: string[] = []
70+
let m: RegExpExecArray | null
71+
// eslint-disable-next-line no-cond-assign
72+
while (m = RE_ARRAY_ITEM.exec(trimmed))
73+
names.push(m[1])
74+
RE_ARRAY_ITEM.lastIndex = 0
75+
return names
76+
}
77+
78+
// Runtime object syntax: defineProps({ title: String })
79+
if (trimmed.startsWith('{'))
80+
return extractTopLevelKeys(trimmed, RE_RUNTIME_PROP_KEY)
81+
82+
return []
83+
}
84+
85+
/**
86+
* Extract top-level property keys from a braced block, skipping nested braces.
87+
*/
88+
function extractTopLevelKeys(body: string, re: RegExp): string[] {
89+
const keys: string[] = []
90+
let depth = 0
91+
let flat = ''
92+
93+
for (const ch of body) {
94+
if (ch === '{' || ch === '<' || ch === '(') {
95+
depth++
96+
if (depth > 1) { flat += ' '; continue }
97+
}
98+
else if (ch === '}' || ch === '>' || ch === ')') {
99+
if (depth > 1) { flat += ' '; depth--; continue }
100+
depth--
101+
}
102+
flat += depth <= 1 ? ch : ' '
103+
}
104+
105+
let m: RegExpExecArray | null
106+
// eslint-disable-next-line no-cond-assign
107+
while (m = re.exec(flat))
108+
keys.push(m[1])
109+
re.lastIndex = 0
110+
return keys
111+
}

src/module.ts

Lines changed: 5 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 } 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'
@@ -1009,6 +1010,7 @@ export default defineNuxtModule<ModuleOptions>({
10091010
ogImageComponentCtx.detectedRenderers.add(renderer)
10101011
const componentFile = fs.readFileSync(component.filePath, 'utf-8')
10111012
const credits = componentFile.split('\n').find(line => line.startsWith(' * @credits'))?.replace('* @credits', '').trim()
1013+
const propNames = extractPropNamesFromVue(componentFile)
10121014
ogImageComponentCtx.components.push({
10131015
hash: hash(componentFile).replaceAll('_', '-'),
10141016
pascalName: component.pascalName,
@@ -1017,6 +1019,7 @@ export default defineNuxtModule<ModuleOptions>({
10171019
category,
10181020
credits,
10191021
renderer,
1022+
propNames,
10201023
})
10211024
}
10221025
})
@@ -1048,13 +1051,15 @@ export default defineNuxtModule<ModuleOptions>({
10481051
})) {
10491052
return
10501053
}
1054+
const communityFile = fs.readFileSync(filePath, 'utf-8')
10511055
ogImageComponentCtx.components.push({
10521056
hash: '',
10531057
pascalName,
10541058
kebabName: pascalName.replace(RE_PASCAL_TO_KEBAB, '$1-$2').toLowerCase(),
10551059
path: filePath,
10561060
category: 'community',
10571061
renderer,
1062+
propNames: extractPropNamesFromVue(communityFile),
10581063
})
10591064
})
10601065
}

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

Lines changed: 18 additions & 0 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'
@@ -145,6 +146,23 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
145146
// Normalise options and get renderer from component metadata
146147
const normalised = normaliseOptions(options)
147148

149+
// Whitelist props: only allow props declared in the component's defineProps.
150+
// Components without defineProps accept no props. Prevents cache key inflation
151+
// from arbitrary query params (DoS vector).
152+
if (normalised.component && normalised.options.props && typeof normalised.options.props === 'object') {
153+
const allowedProps = normalised.component.propNames || []
154+
const allowedSet = new Set(allowedProps)
155+
const raw = normalised.options.props as Record<string, any>
156+
const filtered: Record<string, any> = {}
157+
for (const key of Object.keys(raw)) {
158+
if (allowedSet.has(key))
159+
filtered[key] = raw[key]
160+
else if (import.meta.dev)
161+
logger.warn(`[Nuxt OG Image] Prop "${key}" is not declared by component "${normalised.component.pascalName}" and was dropped. Declared props: ${allowedProps.join(', ')}`)
162+
}
163+
normalised.options.props = filtered
164+
}
165+
148166
// Auto-eject community templates in dev mode (skip devtools requests)
149167
if (normalised.component?.category === 'community')
150168
autoEjectCommunityTemplate(normalised.component, runtimeConfig, { requestPath: e.path })

src/runtime/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export interface OgImageComponent {
8888
category: 'app' | 'community' | 'pro'
8989
credits?: string
9090
renderer: RendererType
91+
/** Declared prop names extracted from defineProps at build time (used for prop whitelisting) */
92+
propNames?: string[]
9193
}
9294

9395
export interface ScreenshotOptions {

test/unit/props.test.ts

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

0 commit comments

Comments
 (0)