Skip to content

fix: harden security defaults#540

Merged
harlan-zw merged 12 commits intomainfrom
worktree-fix+dos-security
Mar 26, 2026
Merged

fix: harden security defaults#540
harlan-zw merged 12 commits intomainfrom
worktree-fix+dos-security

Conversation

@harlan-zw
Copy link
Copy Markdown
Collaborator

@harlan-zw harlan-zw commented Mar 26, 2026

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking change

📚 Description

The /_og endpoint lacked runtime protections against abuse. This adds a security config key with multiple layers of defense:

Dimension and render limits (always on):

  • maxDimension (default 2048): clamps width and height
  • maxDpr (default 2): caps Takumi devicePixelRatio
  • renderTimeout (default 10000): aborts renders with a 408

SSRF protection (always on outside dev):

  • Blocks <img src> and background-image fetches to private/loopback IPs
  • Handles bypass vectors: hex IPs, decimal IPs, IPv6-mapped IPv4, non-http protocols

Host restriction (opt-in):

  • restrictRuntimeImagesToOrigin: true checks the Host header against the site config URL
  • Accepts string[] for additional allowed hosts

Query string size limit (opt-in):

  • maxQueryParamSize rejects oversized query strings with a 400
  • Default null (no limit); recommended value 2048

Quick start:

export default defineNuxtConfig({
  ogImage: {
    security: {
      restrictRuntimeImagesToOrigin: true,
      maxQueryParamSize: 2048,
    }
  }
})

Includes a Security Guide covering prerender-first guidance and all security options.

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`.
@harlan-zw harlan-zw changed the title fix: prevent DoS via image generation (GHSA-c7xp-q6q8-hg76) fix: better security defaults Mar 26, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 26, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nuxt-og-image@540

commit: 9b9536f

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
@harlan-zw harlan-zw changed the title fix: better security defaults fix: harden security defaults Mar 26, 2026
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.
@harlan-zw
Copy link
Copy Markdown
Collaborator Author

Code review

Found 3 issues:

  1. SSRF bypass: blocked URL left in node.props.src for Satori to re-fetch. When isBlockedUrl(src) returns true, the code logs a warning but node.props.src was already set to the private URL on the preceding line. Satori will attempt to fetch this URL at render time, completely bypassing the SSRF protection. The blocked URL should be removed or replaced with a safe placeholder (e.g. empty data: URI).

src = decodeHtml(src)
node.props.src = src
// Block private/loopback URLs outside dev to prevent SSRF
if (!import.meta.dev && isBlockedUrl(src)) {
logger.warn(`Blocked internal image fetch: ${src}`)
}

  1. Dimension clamping skipped for query param values. The clamp uses typeof options.width === 'number', but query params are stored as strings (line 100: String(query[k])). A request like ?width=20000&height=20000 passes through unclamped because typeof "20000" === 'number' is false. The string reaches takumi/renderer.ts where Number(options.width) * dpr processes the oversized value. Either coerce to number before clamping or use Number(options.width) in the guard.

// Clamp dimensions to prevent DoS via oversized image generation (GHSA-c7xp-q6q8-hg76)
const maxDim = runtimeConfig.security?.maxDimension || 2048
if (typeof options.width === 'number')
options.width = Math.min(Math.max(1, options.width), maxDim)
if (typeof options.height === 'number')
options.height = Math.min(Math.max(1, options.height), maxDim)

  1. Render timeout leaks setTimeout and does not cancel the in-flight render. Promise.race rejects on timeout, but the setTimeout handle is never cleared when the render completes first (every successful render leaks a 15s timer). Additionally, renderer.createImage(ctx) continues executing in the background after a timeout, consuming resources. Store the timer handle and clearTimeout on resolution.

image = await Promise.race([
renderer.createImage(ctx),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`OG image render timed out after ${timeout}ms`)), timeout),
),
]).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.` })
}
logger.error(`renderer.createImage error for ${e.path}:`, err?.stack || err?.message || err)
throw err
})

🤖 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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 security runtime 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.` })
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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).` })

Copilot uses AI. Check for mistakes.
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.')
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.')

Copilot uses AI. Check for mistakes.
Comment on lines +207 to +208
/** Render timeout in milliseconds. Returns 408 on timeout. @default 15000 */
renderTimeout?: number
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +54
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)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
// 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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines 221 to +229
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as for : when a background-image URL is blocked, the style remains url(...) and downstream rendering can still fetch it. To make SSRF prevention effective, update the node to remove/neutralize the backgroundImage when blocked (or swap in a safe data URI).

Copilot uses AI. Check for mistakes.
Comment on lines +146 to +151
// 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
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
- 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
@harlan-zw harlan-zw merged commit 9902a89 into main Mar 26, 2026
10 checks passed
@harlan-zw harlan-zw deleted the worktree-fix+dos-security branch March 26, 2026 15:31
@n0099
Copy link
Copy Markdown

n0099 commented Apr 1, 2026

CVE-2026-34404 GHSA-pqhr-mp3f-hrpp

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants