@@ -16,6 +16,7 @@ import { useNitroApp } from 'nitropack/runtime'
1616import { hash } from 'ohash'
1717import { parseURL , withoutLeadingSlash , withoutTrailingSlash , withQuery } from 'ufo'
1818import { normalizeKey } from 'unstorage'
19+ import { logger } from '../../logger'
1920import { decodeOgImageParams , extractEncodedSegment , sanitizeProps , separateProps } from '../../shared'
2021import { autoEjectCommunityTemplate } from '../util/auto-eject'
2122import { createNitroRouteRuleMatcher } from '../util/kit'
@@ -25,17 +26,19 @@ import { getBrowserRenderer, getSatoriRenderer, getTakumiRenderer } from './inst
2526
2627const RE_HASH_MODE = / ^ o _ ( [ a - z 0 - 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
0 commit comments