Skip to content

Commit dfa4bc5

Browse files
committed
Merge remote-tracking branch 'origin/main'
2 parents c96607b + 7a9f03e commit dfa4bc5

5 files changed

Lines changed: 132 additions & 17 deletions

File tree

src/build/prerender.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,41 @@ export function setupPrerenderHandler(options: ModuleOptions, resolve: Resolver,
2121
// avoid wasm handling while prerendering
2222
nitroConfig.wasm = nitroConfig.wasm || {}
2323
nitroConfig.wasm.esmImport = false
24+
// Dynamic OG URLs are runtime-only. Prevent nitro's crawler from picking
25+
// them up via HTML meta extraction and writing them to disk as filenames,
26+
// which would hit the filesystem 255-byte limit for long signed URLs.
27+
nitroConfig.prerender = nitroConfig.prerender || {}
28+
nitroConfig.prerender.ignore = nitroConfig.prerender.ignore || []
29+
if (Array.isArray(nitroConfig.prerender.ignore))
30+
nitroConfig.prerender.ignore.push('/_og/d/')
31+
})
32+
33+
// Track hash-mode OG URLs whose source page isn't in the prerender graph.
34+
// These 404 at context.ts because the page's defineOgImage() never ran so
35+
// the hash:<hash> cache entry was never written. Clear the error so the build
36+
// doesn't fail, and warn once at the end of prerender so users can investigate.
37+
const orphanedOgHashes: string[] = []
38+
nitro.hooks.hook('prerender:route', (route) => {
39+
if (!route.error || route.error.statusCode !== 404)
40+
return
41+
if (!route.route.includes('/_og/s/o_'))
42+
return
43+
orphanedOgHashes.push(route.route)
44+
route.error = undefined
45+
route.contents = ''
46+
route.fileName = undefined
47+
})
48+
49+
nitro.hooks.hook('prerender:done', async () => {
50+
if (orphanedOgHashes.length > 0) {
51+
logger.warn(
52+
`Skipped ${orphanedOgHashes.length} orphaned OG image hash URL${orphanedOgHashes.length > 1 ? 's' : ''} during prerender. `
53+
+ `These URLs were crawled from HTML but their source page was not prerendered, so the hash cache entry was never written. `
54+
+ `If your pages are prerendered but OG images are generated at runtime, enable \`security.strict\` with a \`security.secret\` to switch to signed dynamic URLs.`,
55+
)
56+
for (const route of orphanedOgHashes)
57+
logger.info(` ${route}`)
58+
}
2459
})
2560

2661
// Cleanup old build cache files after prerender

src/runtime/app/utils.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ export function createOgImageMeta(src: string, input: OgImageOptions | OgImagePr
136136
}
137137
// Inline getOgImagePath logic: useRuntimeConfig() is unavailable in lazy callbacks
138138
const extension = opts.extension || defaults?.extension || 'png'
139-
const isStatic = import.meta.prerender
139+
// Force dynamic+signed URLs even during prerender when strict+secret are set.
140+
// Otherwise /_og/s/ URLs baked into HTML are unsigned and 403 at runtime for
141+
// setups where pages are prerendered but OG images are served dynamically.
142+
const isStatic = import.meta.prerender && !(ogImageConfig.security?.secret && ogImageConfig.security?.strict)
140143
const urlOpts: Record<string, any> = { ...opts, _path: payloadBasePath }
141144
const componentName = opts.component || componentNames?.[0]?.pascalName
142145
const component = componentNames?.find((c: any) => c.pascalName === componentName || c.kebabName === componentName)
@@ -147,8 +150,11 @@ export function createOgImageMeta(src: string, input: OgImageOptions | OgImagePr
147150
const finalUrl = opts._query && Object.keys(opts._query).length
148151
? withQuery(resolvedUrl, { _query: opts._query })
149152
: resolvedUrl
150-
// Update prerender paths to match the lazily resolved URL
151-
if (import.meta.prerender && ssrContext.event) {
153+
// Update prerender paths to match the lazily resolved URL.
154+
// Only emit for static URLs: dynamic (/_og/d/) URLs are runtime-only and
155+
// must not be force-prerendered via x-nitro-prerender, because long ones
156+
// would hit the filesystem 255-byte filename limit when nitro writes them.
157+
if (isStatic && import.meta.prerender && ssrContext.event) {
152158
const prerenderPaths: Map<string, string> | undefined = ssrContext.event.context._ogImagePrerenderPaths
153159
if (prerenderPaths) {
154160
const ogKey = opts.key || 'og'
@@ -275,7 +281,10 @@ export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOpti
275281
const baseURL = runtimeConfig.app.baseURL
276282
const { defaults, security } = useOgImageRuntimeConfig()
277283
const extension = _options?.extension || defaults?.extension || 'png'
278-
const isStatic = import.meta.prerender
284+
// Force dynamic+signed URLs even during prerender when strict+secret are set.
285+
// Otherwise /_og/s/ URLs baked into HTML are unsigned and 403 at runtime for
286+
// setups where pages are prerendered but OG images are served dynamically.
287+
const isStatic = import.meta.prerender && !(security?.secret && security?.strict)
279288
const options: Record<string, any> = { ..._options, _path: _pagePath }
280289
// Include the component template hash so that template changes produce different URLs,
281290
// busting CDN/build caches (Vercel, social platform crawlers like Twitter/Facebook, etc.)

src/runtime/server/util/eventHandlers.ts

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,47 @@ export async function imageEventHandler(e: H3Event) {
2020
const { isDevToolsContextRequest, extension, renderer } = ctx
2121
const { debug, baseCacheKey, security } = useOgImageRuntimeConfig()
2222

23-
// Origin restriction: block runtime requests from unknown hosts
23+
// Origin restriction: block runtime requests from unknown hosts.
24+
// Loopback requests (localhost, 127.0.0.1, ::1) are allowed only when URL
25+
// signing is active, so production builds running locally for e2e/CI don't
26+
// need to disable the check entirely. Without a secret we cannot trust the
27+
// Host / X-Forwarded-Host headers (user-controlled), so the allowlist must
28+
// be enforced. With a secret, HMAC verification is what actually protects
29+
// these requests; the host check is just an extra layer.
2430
if (!import.meta.prerender && !import.meta.dev && security?.restrictRuntimeImagesToOrigin) {
25-
const siteHost = new URL(getSiteConfig(e).url).host
26-
const allowedHosts = [siteHost, ...security.restrictRuntimeImagesToOrigin.map((o) => {
31+
const requestHost = getRequestHost(e, { xForwardedHost: true })
32+
// Parse the hostname via URL so bracketed IPv6 hosts like `[::1]:3000`
33+
// are handled correctly (split(':') would yield `[` as the first segment).
34+
let requestHostname: string | undefined
35+
if (requestHost) {
2736
try {
28-
return new URL(o).host
37+
requestHostname = new URL(`http://${requestHost}`).hostname
2938
}
3039
catch {
31-
return o
40+
requestHostname = undefined
41+
}
42+
}
43+
const isLoopback = !!security.secret && (
44+
requestHostname === 'localhost'
45+
|| requestHostname === '127.0.0.1'
46+
|| requestHostname === '::1'
47+
)
48+
if (!isLoopback) {
49+
const siteHost = new URL(getSiteConfig(e).url).host
50+
const allowedHosts = [siteHost, ...security.restrictRuntimeImagesToOrigin.map((o) => {
51+
try {
52+
return new URL(o).host
53+
}
54+
catch {
55+
return o
56+
}
57+
})]
58+
if (!requestHost || !allowedHosts.includes(requestHost)) {
59+
return createError({
60+
statusCode: 403,
61+
statusMessage: '[Nuxt OG Image] Host not allowed.',
62+
})
3263
}
33-
})]
34-
const requestHost = getRequestHost(e, { xForwardedHost: true })
35-
if (!requestHost || !allowedHosts.includes(requestHost)) {
36-
return createError({
37-
statusCode: 403,
38-
statusMessage: '[Nuxt OG Image] Host not allowed.',
39-
})
4064
}
4165
}
4266
// debug - allow in dev mode OR when debug is enabled in config

src/runtime/server/utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOpti
1414
const baseURL = useRuntimeConfig().app.baseURL
1515
const { defaults, security } = useOgImageRuntimeConfig()
1616
const extension = _options?.extension || defaults.extension
17-
const isStatic = import.meta.prerender
17+
// Force dynamic+signed URLs even during prerender when strict+secret are set.
18+
// Otherwise /_og/s/ URLs baked into HTML are unsigned and 403 at runtime for
19+
// setups where pages are prerendered but OG images are served dynamically.
20+
const isStatic = import.meta.prerender && !(security?.secret && security?.strict)
1821
const options: Record<string, any> = { ..._options, _path: _pagePath }
1922
// Include the component template hash so that template changes produce different URLs,
2023
// busting CDN/build caches (Vercel, social platform crawlers like Twitter/Facebook, etc.)

test/unit/urlEncoding.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,50 @@ describe('urlEncoding', () => {
308308
}, 'png', true, defaults)
309309
expect(result.url).toBe('/_og/s/c_Test,title_Hello.png')
310310
})
311+
312+
// These tests lock in the contract that callers rely on when strict+secret
313+
// are enabled. In that mode, getOgImagePath() passes isStatic=false even
314+
// during prerender so the URLs baked into HTML are signed and dynamic, and
315+
// pass runtime signature verification when served by the /_og/d/** handler.
316+
describe('strict+secret contract', () => {
317+
const SECRET = 'test-secret-key'
318+
319+
it('isStatic=false + secret produces signed dynamic URL', () => {
320+
const result = buildOgImageUrl({ width: 1200 }, 'png', false, undefined, SECRET)
321+
expect(result.url).toMatch(/^\/_og\/d\/w_1200,s_[\w-]+\.png$/)
322+
expect(result.hash).toBeUndefined()
323+
})
324+
325+
it('isStatic=true + secret produces UNSIGNED static URL (existing behavior)', () => {
326+
// Static/prerendered URLs are served directly from disk and bypass the
327+
// route handler, so they don't carry signatures. Preserved as-is.
328+
const result = buildOgImageUrl({ width: 1200 }, 'png', true, undefined, SECRET)
329+
expect(result.url).toBe('/_og/s/w_1200.png')
330+
expect(result.url).not.toMatch(/,s_/)
331+
})
332+
333+
it('long URLs + isStatic=false + secret use signed dynamic, NOT hash mode', () => {
334+
// This is the core of Fix B: when strict+secret decouples isStatic from
335+
// prerender, long URLs must stay in the signed dynamic path instead of
336+
// hash mode (hash mode only works for URLs served from disk).
337+
const longTitle = 'A'.repeat(250)
338+
const result = buildOgImageUrl({ props: { title: longTitle } }, 'png', false, undefined, SECRET)
339+
expect(result.url).toMatch(/^\/_og\/d\//)
340+
expect(result.url).toMatch(/,s_[\w-]+\.png$/)
341+
expect(result.url).not.toMatch(/\/o_[a-z0-9]+\./) // no hash-mode fallback
342+
expect(result.hash).toBeUndefined()
343+
})
344+
345+
it('signature changes when options change', () => {
346+
const a = buildOgImageUrl({ width: 1200 }, 'png', false, undefined, SECRET)
347+
const b = buildOgImageUrl({ width: 1201 }, 'png', false, undefined, SECRET)
348+
const sigA = a.url.match(/,s_([\w-]+)\.png$/)?.[1]
349+
const sigB = b.url.match(/,s_([\w-]+)\.png$/)?.[1]
350+
expect(sigA).toBeDefined()
351+
expect(sigB).toBeDefined()
352+
expect(sigA).not.toBe(sigB)
353+
})
354+
})
311355
})
312356

313357
describe('parseOgImageUrl', () => {

0 commit comments

Comments
 (0)