Conversation
Add `security` config with maxDimension (2048), maxDpr (2), and renderTimeout (10s). Block loopback/private IP image fetches outside dev mode. All configurable via `ogImage.security`.
commit: |
Handle hex IPs (0x7f000001), decimal IPs (2130706433), IPv6-mapped IPv4 (::ffff:127.0.0.1), non-http protocols, and .localhost variants.
Add `restrictRuntimeImagesToOrigin` option to block runtime image requests from unknown origins. Disabled by default since social crawlers omit Origin headers. Harden SSRF protection to handle hex, decimal, and IPv6-mapped IP encodings. Add security guide page.
- Switch from Origin/Referer to Host header check via h3's getRequestHost (with X-Forwarded-Host support), which works with social media crawlers - Add maxQueryParamSize security option (default null/disabled) - Rewrite security guide: quick start section, prerender-first guidance, expanded origin restriction docs with crawler/relative-path coverage - Update JSDoc and API config reference
Remove GHSA references, SSRF details, social media crawler section, and relative path section. Lead with secure defaults messaging and only detail configurable options.
Covers browser renderer which can take longer than 10s.
Code reviewFound 3 issues:
og-image/src/runtime/server/og-image/core/plugins/imageSrc.ts Lines 137 to 142 in 996428d
og-image/src/runtime/server/og-image/context.ts Lines 146 to 151 in 996428d
og-image/src/runtime/server/util/eventHandlers.ts Lines 125 to 137 in 996428d 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- Remove blocked URL from node.props.src to prevent Satori re-fetching - Coerce dimensions to Number before clamping (query params arrive as strings) - Clear setTimeout on successful render to prevent timer leak
There was a problem hiding this comment.
Pull request overview
Adds a new ogImage.security configuration surface and runtime enforcement to harden the /_og image endpoint against common abuse vectors (oversized renders, SSRF via runtime image fetching, long-running renders, and optionally oversized query strings / unknown hosts).
Changes:
- Introduces
securityruntime config (max dimension, max DPR, render timeout, query size limit, optional host restriction) and wires defaults from the module. - Adds runtime enforcement: dimension/DPR clamping, render timeout, query string length rejection, and remote image SSRF filtering.
- Documents the new options and adds an e2e test exercising dimension clamping.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| test/e2e/takumi.test.ts | Adds an e2e test for oversized width/height requests. |
| src/runtime/types.ts | Extends runtime config types with a security section. |
| src/runtime/server/util/eventHandlers.ts | Adds optional host restriction and a render timeout wrapper. |
| src/runtime/server/og-image/takumi/renderer.ts | Enforces max DPR and max dimensions for Takumi rendering. |
| src/runtime/server/og-image/core/plugins/imageSrc.ts | Adds URL blocking logic intended to prevent SSRF for remote image fetches. |
| src/runtime/server/og-image/context.ts | Adds query string size limiting and dimension clamping in request context resolution. |
| src/module.ts | Adds security module options, defaults them into runtime config, and warns about debug in prod. |
| docs/content/4.api/3.config.md | Documents the new security config block in the API reference. |
| docs/content/3.guides/13.security.md | Adds a new Security guide describing defaults and hardening options. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ]).catch((err: any) => { | ||
| if (err?.message?.includes('timed out')) { | ||
| logger.error(`renderer.createImage timeout for ${e.path}`) | ||
| return createError({ statusCode: 408, statusMessage: `[Nuxt OG Image] Render timed out.` }) |
There was a problem hiding this comment.
The timeout implemented via Promise.race doesn't actually abort the underlying render; it only returns a 408 while renderer.createImage(ctx) may continue consuming CPU/memory in the background. If the goal is DoS resistance, consider adding real cancellation (e.g. AbortSignal plumbed into renderers / worker thread termination) or adjust the docs/message to clarify this is a request timeout only. Also consider clearing the timer to avoid accumulating timers under load.
| return createError({ statusCode: 408, statusMessage: `[Nuxt OG Image] Render timed out.` }) | |
| return createError({ statusCode: 408, statusMessage: `[Nuxt OG Image] Request timed out while waiting for OG image render (render may still be running).` }) |
src/module.ts
Outdated
| } | ||
|
|
||
| if (config.debug && !nuxt.options.dev) { | ||
| logger.warn('`ogImage.debug` is enabled in production. This exposes the `/_og/debug.json` endpoint and disables some security restrictions. Disable it before deploying to production.') |
There was a problem hiding this comment.
This warning says debug mode "disables some security restrictions", but in the current runtime code paths the new security checks (host restriction, SSRF blocking, dimension clamp, timeout) don’t appear to be conditioned on debug. Either adjust the warning text to only mention the exposed debug endpoint, or explicitly document/implement which security behaviors are relaxed when debug is enabled.
| logger.warn('`ogImage.debug` is enabled in production. This exposes the `/_og/debug.json` endpoint and disables some security restrictions. Disable it before deploying to production.') | |
| logger.warn('`ogImage.debug` is enabled in production. This exposes the `/_og/debug.json` endpoint and should not be enabled in production. Disable it before deploying to production.') |
| /** Render timeout in milliseconds. Returns 408 on timeout. @default 15000 */ | ||
| renderTimeout?: number |
There was a problem hiding this comment.
PR description mentions a default renderTimeout of 10000ms, but the code/docs added here use 15000ms (15_000). Please align the PR description and/or defaults/docs so users don’t get conflicting guidance.
| it('clamps oversized width/height query params to max dimension', async () => { | ||
| const html = await $fetch('/prefix/takumi') as string | ||
| const ogUrl = extractOgImageUrl(html) | ||
| expect(ogUrl).toBeTruthy() | ||
| const oversizedUrl = `${ogUrl}?width=20000&height=20000` | ||
| const image: ArrayBuffer = await $fetch(oversizedUrl, { responseType: 'arrayBuffer' }) | ||
| expect(Buffer.from(image).length).toBeGreaterThan(0) | ||
| }, 60000) |
There was a problem hiding this comment.
This test exercises a large width/height but only asserts the response is non-empty, which doesn’t verify that clamping actually happened. Since test/utils.ts already has getImageDimensions(Buffer), consider asserting the decoded PNG width/height are <= the configured/default maxDimension to prevent regressions.
| // Block private/loopback URLs outside dev to prevent SSRF | ||
| if (!import.meta.dev && isBlockedUrl(src)) { | ||
| logger.warn(`Blocked internal image fetch: ${src}`) | ||
| delete node.props.src |
There was a problem hiding this comment.
SSRF block currently only logs and skips the prefetch, but leaves node.props.src pointing at the blocked URL. Satori/Takumi can still fetch remote image URLs during rendering/resource extraction, so this does not actually prevent SSRF. Consider replacing the src with a safe placeholder (e.g. empty/transparent data URI) or removing the node/style when blocked so no later fetch can occur.
| delete node.props.src | |
| // Replace blocked URL with a safe transparent placeholder and stop further processing | |
| node.props.src = 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' | |
| return |
| else { | ||
| imageBuffer = (await $fetch(decodeHtml(src), { | ||
| responseType: 'arrayBuffer', | ||
| }).catch(() => {})) as BufferSource | undefined | ||
| const decodedSrc = decodeHtml(src) | ||
| if (!import.meta.dev && isBlockedUrl(decodedSrc)) { | ||
| logger.warn(`Blocked internal background-image fetch: ${decodedSrc}`) | ||
| } | ||
| else { | ||
| imageBuffer = (await $fetch(decodedSrc, { | ||
| responseType: 'arrayBuffer', | ||
| }).catch(() => {})) as BufferSource | undefined |
There was a problem hiding this comment.
| // Clamp dimensions to prevent DoS via oversized image generation | ||
| const maxDim = runtimeConfig.security?.maxDimension || 2048 | ||
| if (options.width != null) { | ||
| const w = Number(options.width) | ||
| options.width = Number.isFinite(w) ? Math.min(Math.max(1, w), maxDim) : undefined | ||
| } |
There was a problem hiding this comment.
Dimension clamping only runs when options.width/height are numbers, but query params are parsed as strings in this function. That means oversized ?width=...&height=... values may bypass this clamp (especially for the satori renderer which uses options.width/height directly). Consider coercing numeric strings here (or moving the clamp to after a normalization step that guarantees numeric width/height).
- Clarify debug warning text (no security restrictions are actually disabled) - Clarify timeout error message (render may still be running) - Assert actual image dimensions in clamping test
❓ Type of change
📚 Description
The
/_ogendpoint lacked runtime protections against abuse. This adds asecurityconfig key with multiple layers of defense:Dimension and render limits (always on):
maxDimension(default2048): clampswidthandheightmaxDpr(default2): caps TakumidevicePixelRatiorenderTimeout(default10000): aborts renders with a408SSRF protection (always on outside dev):
<img src>andbackground-imagefetches to private/loopback IPsHost restriction (opt-in):
restrictRuntimeImagesToOrigin: truechecks theHostheader against the site config URLstring[]for additional allowed hostsQuery string size limit (opt-in):
maxQueryParamSizerejects oversized query strings with a400null(no limit); recommended value2048Quick start:
Includes a Security Guide covering prerender-first guidance and all security options.