Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions docs/content/3.guides/13.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
title: Security
description: Learn about the security defaults and how to further harden your OG image endpoint.
---

Nuxt OG Image ships with secure defaults by default. Image dimensions are clamped, renders are time limited, internal network requests are blocked, and user provided props are sanitized. No configuration is needed for these protections.

Check warning on line 6 in docs/content/3.guides/13.security.md

View workflow job for this annotation

GitHub Actions / ci / lint

Passive voice: "is needed". Consider rewriting in active voice

If you want to lock things down further, the `security` config key gives you additional controls.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
security: {
restrictRuntimeImagesToOrigin: true,
maxQueryParamSize: 2048,
}
}
})
```

## Prerender Your Images

The most effective security measure is to **prerender your OG images at build time** using [Zero Runtime mode](/docs/og-image/guides/zero-runtime). Prerendered images are served as static files with no runtime rendering code in your production build.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
zeroRuntime: true
}
})
```

When zero runtime is enabled:

Check warning on line 33 in docs/content/3.guides/13.security.md

View workflow job for this annotation

GitHub Actions / ci / lint

Passive voice: "is enabled". Consider rewriting in active voice
- No server-side rendering code is included in your production build
- Images are generated once at build time and served as static assets

Check warning on line 35 in docs/content/3.guides/13.security.md

View workflow job for this annotation

GitHub Actions / ci / lint

Passive voice: "are generated". Consider rewriting in active voice
- The `/_og` endpoint is not available at runtime

If your OG images don't need to change dynamically after deployment, this is the recommended approach.

For sites that need a mix of static and dynamic images, you can prerender specific routes while keeping runtime generation available for others. See the [Zero Runtime guide](/docs/og-image/guides/zero-runtime) for configuration details.

## Dimension and Render Limits

Every request has its `width` and `height` clamped to `maxDimension` (default `2048` pixels). The Takumi renderer's `devicePixelRatio` is capped to `maxDpr` (default `2`).

If a render exceeds `renderTimeout` (default `15000ms`), it is aborted and the server returns a `408` status.

These are all enabled by default. You only need to configure them if you want different limits.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
security: {
maxDimension: 2048,
maxDpr: 2,
renderTimeout: 15000,
}
}
})
```

## Query String Size Limit

OG image options can be passed via query parameters. By default there is no size limit on the query string, but you can set `maxQueryParamSize` to reject requests with oversized query strings.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
security: {
maxQueryParamSize: 2048, // characters
}
}
})
```

Requests exceeding this limit receive a `400` response.

If you find yourself passing large amounts of data through query parameters (titles, descriptions, full text), consider loading that data inside your OG image component instead. See the [Performance guide](/docs/og-image/guides/performance#reduce-url-size) for the recommended pattern.

## Restrict Runtime Images to Origin

When runtime image generation is enabled, anyone who knows the `/_og` endpoint pattern can request an image directly. The `restrictRuntimeImagesToOrigin` option limits runtime generation to requests whose `Host` header matches your configured site URL.

Check warning on line 82 in docs/content/3.guides/13.security.md

View workflow job for this annotation

GitHub Actions / ci / lint

Passive voice: "is enabled". Consider rewriting in active voice

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
security: {
restrictRuntimeImagesToOrigin: true,
}
}
})
```

### How It Works

The module reads the `Host` header from each runtime request using h3's `getRequestHost` (with `X-Forwarded-Host` support for reverse proxies) and compares it against the host from your [Nuxt Site Config](https://nuxtseo.com/docs/site-config/getting-started/introduction) `url`. If the hosts don't match, the request receives a `403` response.

Because the `Host` header is mandatory in HTTP/1.1, this check works with all clients including social media crawlers. No `Origin` or `Referer` header is required.

Check warning on line 98 in docs/content/3.guides/13.security.md

View workflow job for this annotation

GitHub Actions / ci / lint

Passive voice: "is required". Consider rewriting in active voice

### Allowing Additional Origins

To allow extra origins (e.g. a CDN or preview deployment), pass an array. Your site config origin is always included automatically.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
security: {
restrictRuntimeImagesToOrigin: ['https://cdn.example.com', 'https://preview.example.com'],
}
}
})
```

This option is **disabled by default** to avoid surprises for sites behind non-standard proxy setups. If your reverse proxy forwards the correct `Host` or `X-Forwarded-Host` header, you can safely enable it.

::note
Prerendering and dev mode bypass the host check entirely.
::

## Debug Mode Warning

Enabling `ogImage.debug` in production exposes the `/_og/debug.json` endpoint. The module will log a warning at build time if debug is enabled outside of dev mode. Make sure to disable it before deploying.

Check warning on line 122 in docs/content/3.guides/13.security.md

View workflow job for this annotation

GitHub Actions / ci / lint

Passive voice: "is enabled". Consider rewriting in active voice

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
debug: false, // never enable in production
}
})
```
26 changes: 26 additions & 0 deletions docs/content/4.api/3.config.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,32 @@ export default defineNuxtConfig({

See the [Browser Renderer](/docs/og-image/renderers/browser) guide for more details.

### `security`

- Type: `object`{lang="ts"}

Security limits for image generation. See the [Security Guide](/docs/og-image/guides/security) for full details.

- **`maxDimension`**: Maximum width or height in pixels. Default `2048`.
- **`maxDpr`**: Maximum device pixel ratio (Takumi renderer). Default `2`.
- **`renderTimeout`**: Milliseconds before the render is aborted with a `408` response. Default `15000`.
- **`maxQueryParamSize`**: Maximum query string length (in characters) for runtime requests. Returns `400` when exceeded. Default `null` (no limit).
- **`restrictRuntimeImagesToOrigin`**: Restrict runtime image generation to requests whose `Host` header matches allowed hosts. Default `false`. See the [Security Guide](/docs/og-image/guides/security#restrict-runtime-images-to-origin) for details.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
ogImage: {
security: {
maxDimension: 2048,
maxDpr: 2,
renderTimeout: 15000,
restrictRuntimeImagesToOrigin: true, // lock to site config URL host
// or: ['https://cdn.example.com'] // allow additional hosts
}
}
})
```

### `debug`

- Type: `boolean`{lang="ts"}
Expand Down
46 changes: 46 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,39 @@ export interface ModuleOptions {
* @example { provider: 'cloudflare', binding: 'BROWSER' }
*/
browser?: BrowserConfig
/**
* Security limits for image generation. Prevents denial of service via
* oversized dimensions, unbounded DPR, or long-running renders.
*/
security?: {
/** Maximum allowed width or height in pixels. @default 2048 */
maxDimension?: number
/** Maximum device pixel ratio (takumi renderer). @default 2 */
maxDpr?: number
/** Render timeout in milliseconds. Returns 408 on timeout. @default 15000 */
renderTimeout?: number
Comment on lines +207 to +208
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.
/**
* Maximum allowed length (in characters) for the query string on runtime OG image requests.
* Requests exceeding this limit receive a 400 response.
*
* Set to a number to enable (e.g. `2048`). Leave `null` to disable.
*
* @default null
*/
maxQueryParamSize?: number | null
/**
* Restrict runtime image generation to requests whose Host header matches allowed hosts.
* - `true`: only allow requests whose Host matches the site config URL host
* - `string[]`: allow the site config URL host plus these additional origins
* - `false` (default): no host restriction
*
* Uses h3's `getRequestHost` with `X-Forwarded-Host` support for reverse proxies.
* Prerendering and dev mode are never restricted.
*
* @default false
*/
restrictRuntimeImagesToOrigin?: boolean | string[]
}
}

export interface ModuleHooks {
Expand Down Expand Up @@ -290,6 +323,10 @@ export default defineNuxtModule<ModuleOptions>({
return
}

if (config.debug && !nuxt.options.dev) {
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.')
}

// Check for removed/deprecated config options
const ogImageConfig = config as unknown as Record<string, unknown>
for (const key of Object.keys(REMOVED_CONFIG)) {
Expand Down Expand Up @@ -1372,6 +1409,15 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
binding: config.browser.binding,
}
: undefined,
security: {
maxDimension: config.security?.maxDimension ?? 2048,
maxDpr: config.security?.maxDpr ?? 2,
renderTimeout: config.security?.renderTimeout ?? 15_000,
maxQueryParamSize: config.security?.maxQueryParamSize ?? null,
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin === true
? []
: (config.security?.restrictRuntimeImagesToOrigin || false),
},
}
if (nuxt.options.dev) {
runtimeConfig.componentDirs = config.componentDirs
Expand Down
23 changes: 23 additions & 0 deletions src/runtime/server/og-image/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
urlOptions = decodeOgImageParams(encodedSegment)
}

// Reject oversized query strings to limit abuse surface
const maxQueryParamSize = runtimeConfig.security?.maxQueryParamSize
if (maxQueryParamSize && !import.meta.prerender) {
const queryString = parseURL(e.path).search || ''
if (queryString.length > maxQueryParamSize) {
return createError({
statusCode: 400,
statusMessage: `[Nuxt OG Image] Query string exceeds maximum allowed length of ${maxQueryParamSize} characters.`,
})
}
}

// Also support query params for backwards compat and dynamic overrides
const query = getQuery(e)
let queryParams: Record<string, any> = {}
Expand Down Expand Up @@ -131,6 +143,17 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
const ogImageRouteRules = separateProps(routeRules.ogImage as RouteRulesOgImage)
const options = defu(queryParams, urlOptions, ogImageRouteRules, runtimeConfig.defaults) as OgImageOptionsInternal

// 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
}
Comment on lines +146 to +151
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.
if (options.height != null) {
const h = Number(options.height)
options.height = Number.isFinite(h) ? Math.min(Math.max(1, h), maxDim) : undefined
}

// Strip HTML event handlers and dangerous attributes from props (GHSA-mg36-wvcr-m75h)
if (options.props && typeof options.props === 'object')
options.props = sanitizeProps(options.props as Record<string, any>)
Expand Down
103 changes: 91 additions & 12 deletions src/runtime/server/og-image/core/plugins/imageSrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,71 @@ import { logger } from '../../../util/logger'
import { getImageDimensions } from '../../utils/image-detector'
import { defineTransformer } from '../plugins'

// SSRF prevention: block private/loopback URLs outside dev mode
const RE_IPV6_BRACKETS = /^\[|\]$/g
const RE_MAPPED_V4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/
const RE_DIGIT_ONLY = /^\d+$/
const RE_INT_IP = /^(?:0x[\da-f]+|\d+)$/i

function isPrivateIPv4(a: number, b: number): boolean {
if (a === 127)
return true // loopback
if (a === 10)
return true // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31)
return true // 172.16.0.0/12
if (a === 192 && b === 168)
return true // 192.168.0.0/16
if (a === 169 && b === 254)
return true // link-local
if (a === 0)
return true // 0.0.0.0/8
return false
}

/**
* Block URLs targeting internal/private networks.
* Handles standard IPs, hex (0x7f000001), decimal (2130706433),
* IPv6-mapped IPv4 (::ffff:127.0.0.1), and localhost.
* Only http/https protocols are allowed.
*/
function isBlockedUrl(url: string): boolean {
let parsed: URL
try {
parsed = new URL(url)
}
catch {
return true
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
return true
const hostname = parsed.hostname.toLowerCase()
const bare = hostname.replace(RE_IPV6_BRACKETS, '')
if (bare === 'localhost' || bare.endsWith('.localhost'))
return true
// Normalize IPv6-mapped IPv4 (::ffff:1.2.3.4)
const mappedV4 = bare.match(RE_MAPPED_V4)
const ip = mappedV4 ? mappedV4[1]! : bare
// Standard dotted-decimal IPv4
const parts = ip.split('.')
if (parts.length === 4 && parts.every(p => RE_DIGIT_ONLY.test(p))) {
const octets = parts.map(Number)
if (octets.some(o => o > 255))
return true
return isPrivateIPv4(octets[0]!, octets[1]!)
}
// Single integer (decimal/hex) IP: e.g. 2130706433 or 0x7f000001
if (RE_INT_IP.test(ip)) {
const num = Number(ip)
if (!Number.isNaN(num) && num >= 0 && num <= 0xFFFFFFFF)
return isPrivateIPv4((num >> 24) & 0xFF, (num >> 16) & 0xFF)
}
// IPv6 private ranges
if (bare === '::1' || bare.startsWith('fc') || bare.startsWith('fd') || bare.startsWith('fe80'))
return true
return false
}

const RE_URL_LEADING = /^url\(['"]?/
const RE_URL_TRAILING = /['"]?\)$/

Expand Down Expand Up @@ -70,15 +135,22 @@ export default defineTransformer([
// avoid trying to fetch base64 image uris
else if (!src.startsWith('data:')) {
src = decodeHtml(src)
node.props.src = src
// fetch remote images and embed as base64 to avoid satori re-fetching at render time
imageBuffer = (await $fetch(src, {
responseType: 'arrayBuffer',
})
.catch(() => {})) as BufferSource | undefined
if (imageBuffer) {
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer as ArrayBuffer
node.props.src = toBase64Image(buffer)
// 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.
}
else {
node.props.src = src
// fetch remote images and embed as base64 to avoid satori re-fetching at render time
imageBuffer = (await $fetch(src, {
responseType: 'arrayBuffer',
})
.catch(() => {})) as BufferSource | undefined
if (imageBuffer) {
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer as ArrayBuffer
node.props.src = toBase64Image(buffer)
}
}
}

Expand Down Expand Up @@ -147,9 +219,16 @@ export default defineTransformer([
}
}
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}`)
delete node.props.style!.backgroundImage
}
else {
imageBuffer = (await $fetch(decodedSrc, {
responseType: 'arrayBuffer',
}).catch(() => {})) as BufferSource | undefined
Comment on lines 221 to +230
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.
}
}
if (imageBuffer) {
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer as ArrayBuffer
Expand Down
Loading
Loading