Skip to content

Commit 9902a89

Browse files
authored
fix: harden security defaults (#540)
1 parent 3dcf8c1 commit 9902a89

File tree

9 files changed

+384
-19
lines changed

9 files changed

+384
-19
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
---
2+
title: Security
3+
description: Learn about the security defaults and how to further harden your OG image endpoint.
4+
---
5+
6+
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.
7+
8+
If you want to lock things down further, the `security` config key gives you additional controls.
9+
10+
```ts [nuxt.config.ts]
11+
export default defineNuxtConfig({
12+
ogImage: {
13+
security: {
14+
restrictRuntimeImagesToOrigin: true,
15+
maxQueryParamSize: 2048,
16+
}
17+
}
18+
})
19+
```
20+
21+
## Prerender Your Images
22+
23+
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.
24+
25+
```ts [nuxt.config.ts]
26+
export default defineNuxtConfig({
27+
ogImage: {
28+
zeroRuntime: true
29+
}
30+
})
31+
```
32+
33+
When zero runtime is enabled:
34+
- No server-side rendering code is included in your production build
35+
- Images are generated once at build time and served as static assets
36+
- The `/_og` endpoint is not available at runtime
37+
38+
If your OG images don't need to change dynamically after deployment, this is the recommended approach.
39+
40+
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.
41+
42+
## Dimension and Render Limits
43+
44+
Every request has its `width` and `height` clamped to `maxDimension` (default `2048` pixels). The Takumi renderer's `devicePixelRatio` is capped to `maxDpr` (default `2`).
45+
46+
If a render exceeds `renderTimeout` (default `15000ms`), it is aborted and the server returns a `408` status.
47+
48+
These are all enabled by default. You only need to configure them if you want different limits.
49+
50+
```ts [nuxt.config.ts]
51+
export default defineNuxtConfig({
52+
ogImage: {
53+
security: {
54+
maxDimension: 2048,
55+
maxDpr: 2,
56+
renderTimeout: 15000,
57+
}
58+
}
59+
})
60+
```
61+
62+
## Query String Size Limit
63+
64+
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.
65+
66+
```ts [nuxt.config.ts]
67+
export default defineNuxtConfig({
68+
ogImage: {
69+
security: {
70+
maxQueryParamSize: 2048, // characters
71+
}
72+
}
73+
})
74+
```
75+
76+
Requests exceeding this limit receive a `400` response.
77+
78+
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.
79+
80+
## Restrict Runtime Images to Origin
81+
82+
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.
83+
84+
```ts [nuxt.config.ts]
85+
export default defineNuxtConfig({
86+
ogImage: {
87+
security: {
88+
restrictRuntimeImagesToOrigin: true,
89+
}
90+
}
91+
})
92+
```
93+
94+
### How It Works
95+
96+
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.
97+
98+
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.
99+
100+
### Allowing Additional Origins
101+
102+
To allow extra origins (e.g. a CDN or preview deployment), pass an array. Your site config origin is always included automatically.
103+
104+
```ts [nuxt.config.ts]
105+
export default defineNuxtConfig({
106+
ogImage: {
107+
security: {
108+
restrictRuntimeImagesToOrigin: ['https://cdn.example.com', 'https://preview.example.com'],
109+
}
110+
}
111+
})
112+
```
113+
114+
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.
115+
116+
::note
117+
Prerendering and dev mode bypass the host check entirely.
118+
::
119+
120+
## Debug Mode Warning
121+
122+
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.
123+
124+
```ts [nuxt.config.ts]
125+
export default defineNuxtConfig({
126+
ogImage: {
127+
debug: false, // never enable in production
128+
}
129+
})
130+
```

docs/content/4.api/3.config.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,32 @@ export default defineNuxtConfig({
174174

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

177+
### `security`
178+
179+
- Type: `object`{lang="ts"}
180+
181+
Security limits for image generation. See the [Security Guide](/docs/og-image/guides/security) for full details.
182+
183+
- **`maxDimension`**: Maximum width or height in pixels. Default `2048`.
184+
- **`maxDpr`**: Maximum device pixel ratio (Takumi renderer). Default `2`.
185+
- **`renderTimeout`**: Milliseconds before the render is aborted with a `408` response. Default `15000`.
186+
- **`maxQueryParamSize`**: Maximum query string length (in characters) for runtime requests. Returns `400` when exceeded. Default `null` (no limit).
187+
- **`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.
188+
189+
```ts [nuxt.config.ts]
190+
export default defineNuxtConfig({
191+
ogImage: {
192+
security: {
193+
maxDimension: 2048,
194+
maxDpr: 2,
195+
renderTimeout: 15000,
196+
restrictRuntimeImagesToOrigin: true, // lock to site config URL host
197+
// or: ['https://cdn.example.com'] // allow additional hosts
198+
}
199+
}
200+
})
201+
```
202+
177203
### `debug`
178204

179205
- Type: `boolean`{lang="ts"}

src/module.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,39 @@ export interface ModuleOptions {
195195
* @example { provider: 'cloudflare', binding: 'BROWSER' }
196196
*/
197197
browser?: BrowserConfig
198+
/**
199+
* Security limits for image generation. Prevents denial of service via
200+
* oversized dimensions, unbounded DPR, or long-running renders.
201+
*/
202+
security?: {
203+
/** Maximum allowed width or height in pixels. @default 2048 */
204+
maxDimension?: number
205+
/** Maximum device pixel ratio (takumi renderer). @default 2 */
206+
maxDpr?: number
207+
/** Render timeout in milliseconds. Returns 408 on timeout. @default 15000 */
208+
renderTimeout?: number
209+
/**
210+
* Maximum allowed length (in characters) for the query string on runtime OG image requests.
211+
* Requests exceeding this limit receive a 400 response.
212+
*
213+
* Set to a number to enable (e.g. `2048`). Leave `null` to disable.
214+
*
215+
* @default null
216+
*/
217+
maxQueryParamSize?: number | null
218+
/**
219+
* Restrict runtime image generation to requests whose Host header matches allowed hosts.
220+
* - `true`: only allow requests whose Host matches the site config URL host
221+
* - `string[]`: allow the site config URL host plus these additional origins
222+
* - `false` (default): no host restriction
223+
*
224+
* Uses h3's `getRequestHost` with `X-Forwarded-Host` support for reverse proxies.
225+
* Prerendering and dev mode are never restricted.
226+
*
227+
* @default false
228+
*/
229+
restrictRuntimeImagesToOrigin?: boolean | string[]
230+
}
198231
}
199232

200233
export interface ModuleHooks {
@@ -290,6 +323,10 @@ export default defineNuxtModule<ModuleOptions>({
290323
return
291324
}
292325

326+
if (config.debug && !nuxt.options.dev) {
327+
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.')
328+
}
329+
293330
// Check for removed/deprecated config options
294331
const ogImageConfig = config as unknown as Record<string, unknown>
295332
for (const key of Object.keys(REMOVED_CONFIG)) {
@@ -1372,6 +1409,15 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
13721409
binding: config.browser.binding,
13731410
}
13741411
: undefined,
1412+
security: {
1413+
maxDimension: config.security?.maxDimension ?? 2048,
1414+
maxDpr: config.security?.maxDpr ?? 2,
1415+
renderTimeout: config.security?.renderTimeout ?? 15_000,
1416+
maxQueryParamSize: config.security?.maxQueryParamSize ?? null,
1417+
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin === true
1418+
? []
1419+
: (config.security?.restrictRuntimeImagesToOrigin || false),
1420+
},
13751421
}
13761422
if (nuxt.options.dev) {
13771423
runtimeConfig.componentDirs = config.componentDirs

src/runtime/server/og-image/context.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,18 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
9292
urlOptions = decodeOgImageParams(encodedSegment)
9393
}
9494

95+
// Reject oversized query strings to limit abuse surface
96+
const maxQueryParamSize = runtimeConfig.security?.maxQueryParamSize
97+
if (maxQueryParamSize && !import.meta.prerender) {
98+
const queryString = parseURL(e.path).search || ''
99+
if (queryString.length > maxQueryParamSize) {
100+
return createError({
101+
statusCode: 400,
102+
statusMessage: `[Nuxt OG Image] Query string exceeds maximum allowed length of ${maxQueryParamSize} characters.`,
103+
})
104+
}
105+
}
106+
95107
// Also support query params for backwards compat and dynamic overrides
96108
const query = getQuery(e)
97109
let queryParams: Record<string, any> = {}
@@ -131,6 +143,17 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
131143
const ogImageRouteRules = separateProps(routeRules.ogImage as RouteRulesOgImage)
132144
const options = defu(queryParams, urlOptions, ogImageRouteRules, runtimeConfig.defaults) as OgImageOptionsInternal
133145

146+
// Clamp dimensions to prevent DoS via oversized image generation
147+
const maxDim = runtimeConfig.security?.maxDimension || 2048
148+
if (options.width != null) {
149+
const w = Number(options.width)
150+
options.width = Number.isFinite(w) ? Math.min(Math.max(1, w), maxDim) : undefined
151+
}
152+
if (options.height != null) {
153+
const h = Number(options.height)
154+
options.height = Number.isFinite(h) ? Math.min(Math.max(1, h), maxDim) : undefined
155+
}
156+
134157
// Strip HTML event handlers and dangerous attributes from props (GHSA-mg36-wvcr-m75h)
135158
if (options.props && typeof options.props === 'object')
136159
options.props = sanitizeProps(options.props as Record<string, any>)

src/runtime/server/og-image/core/plugins/imageSrc.ts

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,71 @@ import { logger } from '../../../util/logger'
88
import { getImageDimensions } from '../../utils/image-detector'
99
import { defineTransformer } from '../plugins'
1010

11+
// SSRF prevention: block private/loopback URLs outside dev mode
12+
const RE_IPV6_BRACKETS = /^\[|\]$/g
13+
const RE_MAPPED_V4 = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/
14+
const RE_DIGIT_ONLY = /^\d+$/
15+
const RE_INT_IP = /^(?:0x[\da-f]+|\d+)$/i
16+
17+
function isPrivateIPv4(a: number, b: number): boolean {
18+
if (a === 127)
19+
return true // loopback
20+
if (a === 10)
21+
return true // 10.0.0.0/8
22+
if (a === 172 && b >= 16 && b <= 31)
23+
return true // 172.16.0.0/12
24+
if (a === 192 && b === 168)
25+
return true // 192.168.0.0/16
26+
if (a === 169 && b === 254)
27+
return true // link-local
28+
if (a === 0)
29+
return true // 0.0.0.0/8
30+
return false
31+
}
32+
33+
/**
34+
* Block URLs targeting internal/private networks.
35+
* Handles standard IPs, hex (0x7f000001), decimal (2130706433),
36+
* IPv6-mapped IPv4 (::ffff:127.0.0.1), and localhost.
37+
* Only http/https protocols are allowed.
38+
*/
39+
function isBlockedUrl(url: string): boolean {
40+
let parsed: URL
41+
try {
42+
parsed = new URL(url)
43+
}
44+
catch {
45+
return true
46+
}
47+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
48+
return true
49+
const hostname = parsed.hostname.toLowerCase()
50+
const bare = hostname.replace(RE_IPV6_BRACKETS, '')
51+
if (bare === 'localhost' || bare.endsWith('.localhost'))
52+
return true
53+
// Normalize IPv6-mapped IPv4 (::ffff:1.2.3.4)
54+
const mappedV4 = bare.match(RE_MAPPED_V4)
55+
const ip = mappedV4 ? mappedV4[1]! : bare
56+
// Standard dotted-decimal IPv4
57+
const parts = ip.split('.')
58+
if (parts.length === 4 && parts.every(p => RE_DIGIT_ONLY.test(p))) {
59+
const octets = parts.map(Number)
60+
if (octets.some(o => o > 255))
61+
return true
62+
return isPrivateIPv4(octets[0]!, octets[1]!)
63+
}
64+
// Single integer (decimal/hex) IP: e.g. 2130706433 or 0x7f000001
65+
if (RE_INT_IP.test(ip)) {
66+
const num = Number(ip)
67+
if (!Number.isNaN(num) && num >= 0 && num <= 0xFFFFFFFF)
68+
return isPrivateIPv4((num >> 24) & 0xFF, (num >> 16) & 0xFF)
69+
}
70+
// IPv6 private ranges
71+
if (bare === '::1' || bare.startsWith('fc') || bare.startsWith('fd') || bare.startsWith('fe80'))
72+
return true
73+
return false
74+
}
75+
1176
const RE_URL_LEADING = /^url\(['"]?/
1277
const RE_URL_TRAILING = /['"]?\)$/
1378

@@ -70,15 +135,22 @@ export default defineTransformer([
70135
// avoid trying to fetch base64 image uris
71136
else if (!src.startsWith('data:')) {
72137
src = decodeHtml(src)
73-
node.props.src = src
74-
// fetch remote images and embed as base64 to avoid satori re-fetching at render time
75-
imageBuffer = (await $fetch(src, {
76-
responseType: 'arrayBuffer',
77-
})
78-
.catch(() => {})) as BufferSource | undefined
79-
if (imageBuffer) {
80-
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer as ArrayBuffer
81-
node.props.src = toBase64Image(buffer)
138+
// Block private/loopback URLs outside dev to prevent SSRF
139+
if (!import.meta.dev && isBlockedUrl(src)) {
140+
logger.warn(`Blocked internal image fetch: ${src}`)
141+
delete node.props.src
142+
}
143+
else {
144+
node.props.src = src
145+
// fetch remote images and embed as base64 to avoid satori re-fetching at render time
146+
imageBuffer = (await $fetch(src, {
147+
responseType: 'arrayBuffer',
148+
})
149+
.catch(() => {})) as BufferSource | undefined
150+
if (imageBuffer) {
151+
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer as ArrayBuffer
152+
node.props.src = toBase64Image(buffer)
153+
}
82154
}
83155
}
84156

@@ -147,9 +219,16 @@ export default defineTransformer([
147219
}
148220
}
149221
else {
150-
imageBuffer = (await $fetch(decodeHtml(src), {
151-
responseType: 'arrayBuffer',
152-
}).catch(() => {})) as BufferSource | undefined
222+
const decodedSrc = decodeHtml(src)
223+
if (!import.meta.dev && isBlockedUrl(decodedSrc)) {
224+
logger.warn(`Blocked internal background-image fetch: ${decodedSrc}`)
225+
delete node.props.style!.backgroundImage
226+
}
227+
else {
228+
imageBuffer = (await $fetch(decodedSrc, {
229+
responseType: 'arrayBuffer',
230+
}).catch(() => {})) as BufferSource | undefined
231+
}
153232
}
154233
if (imageBuffer) {
155234
const buffer = imageBuffer instanceof ArrayBuffer ? imageBuffer : imageBuffer.buffer as ArrayBuffer

0 commit comments

Comments
 (0)