Skip to content

Commit 1902f67

Browse files
authored
fix: avoid leaking useHead title into og:image URL (#573) (#574)
1 parent 3aa7644 commit 1902f67

4 files changed

Lines changed: 82 additions & 30 deletions

File tree

src/runtime/app/utils.ts

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ const RE_RENDERER_SUFFIX = /(Satori|Browser|Takumi)$/
2121
type OgImagePayload = [string, OgImageOptionsInternal, string]
2222

2323
/**
24-
* Extract title and description from head entries set by useSeoMeta / useHead.
24+
* Extract title and description from head entries set by useSeoMeta.
2525
*
26-
* useSeoMeta stores description in `_flatMeta` (flat object keyed by meta name),
27-
* while useHead stores it in `input.meta` (array of { name, content } objects).
28-
* Title is hoisted to `entry.input.title` by both APIs.
26+
* Only pulls from useSeoMeta entries (identified by the `_flatMeta` marker)
27+
* so plain `useHead({ title })` page titles don't leak into og:image URLs (#573).
28+
* useSeoMeta hoists title to `entry.input.title` and description to `_flatMeta`.
2929
*/
3030
function extractHeadSeoProps(head: VueHeadClient): { title?: string, description?: string } {
3131
const result: { title?: string, description?: string } = {}
@@ -35,33 +35,20 @@ function extractHeadSeoProps(head: VueHeadClient): { title?: string, description
3535
if (!input || typeof input !== 'object')
3636
continue
3737

38-
// Title: both useSeoMeta and useHead hoist title to entry.input.title
38+
// `_flatMeta` is only populated by useSeoMeta — use it to distinguish from useHead.
39+
const flatMeta = input._flatMeta
40+
if (!flatMeta || typeof flatMeta !== 'object')
41+
continue
42+
3943
if ('title' in input) {
4044
const t = toValue(input.title)
4145
if (typeof t === 'string')
4246
result.title = t
4347
}
4448

45-
// Description from useSeoMeta: stored in _flatMeta
46-
if (input._flatMeta && typeof input._flatMeta === 'object') {
47-
const d = toValue(input._flatMeta.description) || toValue(input._flatMeta.ogDescription)
48-
if (typeof d === 'string')
49-
result.description = d
50-
}
51-
52-
// Description from useHead({ meta: [...] })
53-
if (Array.isArray(input.meta)) {
54-
for (const meta of input.meta) {
55-
const m = toValue(meta)
56-
if (!m || typeof m !== 'object')
57-
continue
58-
if (m.name === 'description' || m.property === 'og:description') {
59-
const c = toValue(m.content)
60-
if (typeof c === 'string')
61-
result.description = c
62-
}
63-
}
64-
}
49+
const desc = toValue(flatMeta.description) || toValue(flatMeta.ogDescription)
50+
if (typeof desc === 'string')
51+
result.description = desc
6552
}
6653
}
6754
catch (e) {
@@ -127,11 +114,20 @@ export function createOgImageMeta(src: string, input: OgImageOptions | OgImagePr
127114
const seo = head ? extractHeadSeoProps(head) : undefined
128115
return finalPayload.flatMap(([_, options, payloadBasePath]) => {
129116
const opts = { ...options, props: { ...options.props } }
130-
// Inject title/description from head entries if not explicitly set
117+
// Resolve component with alias/shorthand support so we know which props it declares.
118+
// Skips auto-injection for props the component doesn't accept
119+
// (#573: useHead({ title }) was leaking into URLs of components without a title prop).
120+
const rawComponentName = opts.component || componentNames?.[0]?.pascalName
121+
const resolvedComponentName = rawComponentName ? resolveComponentName(rawComponentName) : undefined
122+
const resolvedComponent = resolvedComponentName
123+
? componentNames?.find((c: any) => c.pascalName === resolvedComponentName || c.kebabName === resolvedComponentName)
124+
: undefined
125+
const declaredProps = resolvedComponent?.propNames
126+
// Inject title/description from head entries if not explicitly set AND the component declares them
131127
if (seo) {
132-
if (seo.title && typeof opts.props.title === 'undefined')
128+
if (seo.title && typeof opts.props.title === 'undefined' && (!declaredProps || declaredProps.includes('title')))
133129
opts.props.title = seo.title
134-
if (seo.description && typeof opts.props.description === 'undefined')
130+
if (seo.description && typeof opts.props.description === 'undefined' && (!declaredProps || declaredProps.includes('description')))
135131
opts.props.description = seo.description
136132
}
137133
// Inline getOgImagePath logic: useRuntimeConfig() is unavailable in lazy callbacks
@@ -141,6 +137,8 @@ export function createOgImageMeta(src: string, input: OgImageOptions | OgImagePr
141137
// setups where pages are prerendered but OG images are served dynamically.
142138
const isStatic = import.meta.prerender && !(ogImageConfig.security?.secret && ogImageConfig.security?.strict)
143139
const urlOpts: Record<string, any> = { ...opts, _path: payloadBasePath }
140+
// Use a strict (non-aliased) lookup for hash — matches main behavior so URLs
141+
// stay stable for shorthand component names like `Default` or `NuxtSeo.satori`.
144142
const componentName = opts.component || componentNames?.[0]?.pascalName
145143
const component = componentNames?.find((c: any) => c.pascalName === componentName || c.kebabName === componentName)
146144
if (component?.hash)
@@ -190,11 +188,18 @@ export function createOgImageMeta(src: string, input: OgImageOptions | OgImagePr
190188
const seo = head ? extractHeadSeoProps(head) : undefined
191189
const devtoolsPayload = (ssrContext._ogImagePayloads || []).map(([key, options]) => {
192190
const payload = resolveUnrefHeadInput(options) as any
191+
// Resolve component to check declared props before auto-injecting title/description
192+
const rawComponentName = payload.component || componentNames?.[0]?.pascalName
193+
const resolvedComponentName = rawComponentName ? resolveComponentName(rawComponentName) : undefined
194+
const component = resolvedComponentName
195+
? componentNames?.find((c: any) => c.pascalName === resolvedComponentName || c.kebabName === resolvedComponentName)
196+
: undefined
197+
const declaredProps = component?.propNames
193198
// Use %s template param for title so unhead resolves it with titleTemplate
194-
if (payload.props && typeof payload.props.title === 'undefined')
199+
if (payload.props && typeof payload.props.title === 'undefined' && (!declaredProps || declaredProps.includes('title')))
195200
payload.props.title = '%s'
196201
// Inject description from head entries for devtools/prerender cache
197-
if (seo?.description && payload.props && typeof payload.props.description === 'undefined')
202+
if (seo?.description && payload.props && typeof payload.props.description === 'undefined' && (!declaredProps || declaredProps.includes('description')))
198203
payload.props.description = seo.description
199204
if (typeof payload.component === 'string') {
200205
payload.component = resolveComponentName(payload.component)

test/e2e/seo-meta-inject.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,21 @@ describe('useSeoMeta auto-injection', () => {
5050
expect(params.props?.title).toBe('Explicit Title')
5151
expect(params.props?.description).toBe('Explicit description')
5252
}, 60000)
53+
54+
it('does not inject title/description when component does not declare them (#573)', async () => {
55+
const html = await $fetch('/satori/seo-meta-inject-undeclared') as string
56+
const ogUrl = extractOgImageUrl(html)
57+
expect(ogUrl).toBeTruthy()
58+
const params = extractOgParams(ogUrl!)
59+
expect(params.props?.title).toBeUndefined()
60+
expect(params.props?.description).toBeUndefined()
61+
}, 60000)
62+
63+
it('does not inject title from plain useHead (only useSeoMeta) (#573)', async () => {
64+
const html = await $fetch('/satori/seo-meta-inject-usehead') as string
65+
const ogUrl = extractOgImageUrl(html)
66+
expect(ogUrl).toBeTruthy()
67+
const params = extractOgParams(ogUrl!)
68+
expect(params.props?.title).toBeUndefined()
69+
}, 60000)
5370
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script lang="ts" setup>
2+
import { defineOgImage, useHead } from '#imports'
3+
4+
useHead({ title: '1' })
5+
6+
// PropTest only declares a `logo` prop — title from useHead must NOT be auto-injected
7+
defineOgImage('PropTest')
8+
</script>
9+
10+
<template>
11+
<div>
12+
SEO Meta Inject Undeclared Test
13+
</div>
14+
</template>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script lang="ts" setup>
2+
import { defineOgImage, useHead } from '#imports'
3+
4+
// useHead (not useSeoMeta) sets a page title — this must NOT leak into the og:image URL (#573).
5+
useHead({ title: '1' })
6+
7+
// NuxtSeo declares a title prop, but since the title comes from useHead (not useSeoMeta),
8+
// it should still NOT be auto-injected.
9+
defineOgImage('NuxtSeo.satori')
10+
</script>
11+
12+
<template>
13+
<div>
14+
useHead title leakage test
15+
</div>
16+
</template>

0 commit comments

Comments
 (0)