-
-
Notifications
You must be signed in to change notification settings - Fork 48
fix: harden security defaults #540
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
511580c
d660f4c
7186fe8
dbc93f9
2dcc677
63d5c86
4e09f08
0972cd7
996428d
2bc764f
417df66
9b9536f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
|
|
||
| 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: | ||
| - No server-side rendering code is included in your production build | ||
| - Images are generated once at build time and served as static assets | ||
| - 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. | ||
|
|
||
| ```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. | ||
|
|
||
| ### 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. | ||
|
|
||
| ```ts [nuxt.config.ts] | ||
| export default defineNuxtConfig({ | ||
| ogImage: { | ||
| debug: false, // never enable in production | ||
| } | ||
| }) | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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> = {} | ||
|
|
@@ -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
|
||
| 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>) | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 = /['"]?\)$/ | ||||||||||
|
|
||||||||||
|
|
@@ -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 | ||||||||||
|
||||||||||
| 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
AI
Mar 26, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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
renderTimeoutof 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.