Skip to content

Commit 87a2539

Browse files
authored
feat(security): add URL signing to prevent parameter tampering (#546)
1 parent 9c02a2a commit 87a2539

File tree

12 files changed

+383
-35
lines changed

12 files changed

+383
-35
lines changed

docs/content/3.guides/13.security.md

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,66 @@ title: Security
33
description: Learn about the security defaults and how to further harden your OG image endpoint.
44
---
55

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.
6+
Nuxt OG Image ships with secure defaults. 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.
77

8-
If you want to lock things down further, the `security` config key gives you additional controls.
8+
The primary security concern with runtime OG image generation is **denial of service**: without protection, anyone can craft arbitrary image generation requests to your `/_og/d/` endpoint, consuming server CPU and memory. URL signing prevents this by ensuring only your application can generate valid image URLs.
9+
10+
For full protection, we recommend combining URL signing with a **web application firewall** (WAF) or rate limiting on the `/_og/` path prefix. Services like [Cloudflare](https://cloudflare.com), AWS WAF, or your hosting provider's built-in rate limiting can add an additional layer of defense.
911

1012
```ts [nuxt.config.ts]
1113
export default defineNuxtConfig({
1214
ogImage: {
1315
security: {
14-
restrictRuntimeImagesToOrigin: true,
15-
maxQueryParamSize: 2048,
16+
secret: process.env.OG_IMAGE_SECRET,
1617
}
1718
}
1819
})
1920
```
2021

22+
## URL Signing
23+
24+
When a signing secret is configured, every OG image URL includes a cryptographic signature in the path. The server verifies this signature before rendering, rejecting any URL that has been tampered with or crafted manually.
25+
26+
This prevents unauthorized image generation requests that would otherwise consume server resources.
27+
28+
### Setup
29+
30+
1. Generate a secret:
31+
32+
```bash
33+
npx nuxt-og-image generate-secret
34+
```
35+
36+
2. Set the environment variable and reference it in your config:
37+
38+
```ts [nuxt.config.ts]
39+
export default defineNuxtConfig({
40+
ogImage: {
41+
security: {
42+
secret: process.env.OG_IMAGE_SECRET,
43+
}
44+
}
45+
})
46+
```
47+
48+
### How It Works
49+
50+
When a secret is configured:
51+
- `defineOgImage()`{lang="ts"} appends a signature to the URL path: `/_og/d/w_1200,h_600,s_abc123def456.png`
52+
- The server extracts and verifies the signature before processing the request
53+
- Requests with missing or invalid signatures receive a `403` response
54+
- All query parameter overrides are ignored (the signed path is the single source of truth)
55+
56+
The signature is deterministic: the same options with the same secret always produce the same URL. This means URLs are stable across server restarts and deployments as long as the secret does not change.
57+
58+
### Defense in Depth
59+
60+
URL signing works alongside the other security options (`maxDimension`, `maxQueryParamSize`, `renderTimeout`, `restrictRuntimeImagesToOrigin`) which continue to apply as defense-in-depth. When signing is active, query parameter overrides are ignored but the query string size limit still applies to reduce parsing overhead.
61+
62+
::note
63+
Dev mode and prerendering bypass signature verification. Signing only applies to runtime requests in production.
64+
::
65+
2166
## Prerender Your Images
2267

2368
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.
@@ -61,7 +106,7 @@ export default defineNuxtConfig({
61106

62107
## Query String Size Limit
63108

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.
109+
OG image options can be passed via query parameters when URL signing is not enabled. You can set `maxQueryParamSize` to reject requests with oversized query strings.
65110

66111
```ts [nuxt.config.ts]
67112
export default defineNuxtConfig({
@@ -75,6 +120,10 @@ export default defineNuxtConfig({
75120

76121
Requests exceeding this limit receive a `400` response.
77122

123+
::note
124+
When URL signing is active, query parameter overrides are ignored, but this size limit still applies to reduce request parsing overhead.
125+
::
126+
78127
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.
79128

80129
## Restrict Runtime Images to Origin

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ See the [Browser Renderer](/docs/og-image/renderers/browser) guide for more deta
180180

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

183+
- **`secret`**: Signing secret for URL tamper protection. When set, all runtime OG image URLs are signed and unsigned requests are rejected with `403`. Generate one with `npx nuxt-og-image generate-secret`. See the [Security Guide](/docs/og-image/guides/security#url-signing) for details.
183184
- **`maxDimension`**: Maximum width or height in pixels. Default `2048`.
184185
- **`maxDpr`**: Maximum device pixel ratio (Takumi renderer). Default `2`.
185186
- **`renderTimeout`**: Milliseconds before the render is aborted with a `408` response. Default `15000`.
@@ -190,6 +191,7 @@ Security limits for image generation. See the [Security Guide](/docs/og-image/gu
190191
export default defineNuxtConfig({
191192
ogImage: {
192193
security: {
194+
secret: process.env.OG_IMAGE_SECRET,
193195
maxDimension: 2048,
194196
maxDpr: 2,
195197
renderTimeout: 15000,

src/cli.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env node
2+
import { randomBytes } from 'node:crypto'
23
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
34
import { fileURLToPath } from 'node:url'
45
import * as p from '@clack/prompts'
@@ -1660,6 +1661,26 @@ async function runEnable(renderer: string, args: string[]): Promise<void> {
16601661
p.outro('Done')
16611662
}
16621663

1664+
function generateSecret() {
1665+
const secret = randomBytes(32).toString('hex')
1666+
p.intro('nuxt-og-image generate-secret')
1667+
p.note([
1668+
`${secret}`,
1669+
'',
1670+
'Add this to your nuxt.config.ts:',
1671+
'',
1672+
' ogImage: {',
1673+
' security: {',
1674+
` secret: process.env.OG_IMAGE_SECRET,`,
1675+
' }',
1676+
' }',
1677+
'',
1678+
'Then set the environment variable:',
1679+
` OG_IMAGE_SECRET=${secret}`,
1680+
].join('\n'), 'Generated Secret')
1681+
p.outro('')
1682+
}
1683+
16631684
function showHelp() {
16641685
p.intro('nuxt-og-image CLI')
16651686
p.note([
@@ -1673,6 +1694,7 @@ function showHelp() {
16731694
' Options: --edge (install wasm versions for edge runtimes)',
16741695
'migrate v6 Migrate to v6 (component suffixes + new API)',
16751696
' Options: --dry-run, --yes, --renderer <renderer>',
1697+
'generate-secret Generate a signing secret for URL tamper protection',
16761698
].join('\n'), 'Commands')
16771699
p.outro('')
16781700
}
@@ -1713,6 +1735,9 @@ else if (command === 'migrate') {
17131735
else if (command === 'switch') {
17141736
runSwitch(args.slice(1))
17151737
}
1738+
else if (command === 'generate-secret') {
1739+
generateSecret()
1740+
}
17161741
else if (command === 'enable') {
17171742
const renderer = args[1]
17181743
if (!renderer) {

src/module.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,15 @@ export interface ModuleOptions {
228228
* @default false
229229
*/
230230
restrictRuntimeImagesToOrigin?: boolean | string[]
231+
/**
232+
* Secret for URL signing. When set, all runtime OG image URLs include a
233+
* keyed hash signature and the handler rejects requests with missing or
234+
* invalid signatures.
235+
*
236+
* Must be a stable string across deployments. Generate one with:
237+
* `npx nuxt-og-image generate-secret`
238+
*/
239+
secret?: string
231240
}
232241
}
233242

@@ -328,6 +337,20 @@ export default defineNuxtModule<ModuleOptions>({
328337
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.')
329338
}
330339

340+
if (nuxt.options.dev && !config.zeroRuntime && !config.security?.secret) {
341+
logger.warn([
342+
'OG image URLs are not signed. Anyone can craft arbitrary image generation requests.',
343+
'',
344+
'Either set a signing secret:',
345+
' ogImage: { security: { secret: process.env.OG_IMAGE_SECRET } }',
346+
'',
347+
' Generate one with: npx nuxt-og-image generate-secret',
348+
'',
349+
'Or enable zero-runtime mode to disable dynamic generation entirely:',
350+
' ogImage: { zeroRuntime: true }',
351+
].join('\n'))
352+
}
353+
331354
// Check for removed/deprecated config options
332355
const ogImageConfig = config as unknown as Record<string, unknown>
333356
for (const key of Object.keys(REMOVED_CONFIG)) {
@@ -1428,6 +1451,7 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
14281451
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin === true
14291452
? []
14301453
: (config.security?.restrictRuntimeImagesToOrigin || false),
1454+
secret: config.security?.secret || '',
14311455
},
14321456
}
14331457
if (nuxt.options.dev) {

src/runtime/app/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export interface GetOgImagePathResult {
171171
export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult {
172172
const runtimeConfig = useRuntimeConfig()
173173
const baseURL = runtimeConfig.app.baseURL
174-
const { defaults } = useOgImageRuntimeConfig()
174+
const { defaults, security } = useOgImageRuntimeConfig()
175175
const extension = _options?.extension || defaults?.extension || 'png'
176176
const isStatic = import.meta.prerender
177177
const options: Record<string, any> = { ..._options, _path: _pagePath }
@@ -184,7 +184,7 @@ export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOpti
184184
// Build URL with encoded options (Cloudinary-style)
185185
// Include _path so the server knows which page to render
186186
// Pass defaults to skip encoding default values in URL
187-
const result = buildOgImageUrl(options, extension, isStatic, defaults)
187+
const result = buildOgImageUrl(options, extension, isStatic, defaults, security?.secret || undefined)
188188
const path = joinURL('/', baseURL, result.url)
189189
return {
190190
path,

src/runtime/server/og-image/browser/screenshot.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,9 @@ async function takeScreenshot(page: Page, selector: string | undefined, options:
7979
}
8080

8181
export async function createScreenshot({ basePath, e, options, extension }: OgImageRenderEventContext, browser: Browser): Promise<Buffer> {
82-
const { colorPreference, defaults } = useOgImageRuntimeConfig()
82+
const { colorPreference, defaults, security } = useOgImageRuntimeConfig()
8383
// For browser renderer, we need to load the HTML template with options encoded in URL
84-
const path = options.component === 'PageScreenshot' ? basePath : buildOgImageUrl(options, 'html', false, defaults).url
84+
const path = options.component === 'PageScreenshot' ? basePath : buildOgImageUrl(options, 'html', false, defaults, security?.secret || undefined).url
8585

8686
// Create page - Playwright and Puppeteer have different newPage signatures
8787
let page: Page

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

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ import { hash } from 'ohash'
1717
import { parseURL, withoutLeadingSlash, withoutTrailingSlash, withQuery } from 'ufo'
1818
import { normalizeKey } from 'unstorage'
1919
import { logger } from '../../logger'
20-
import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps } from '../../shared'
20+
import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps, verifyOgImageSignature } from '../../shared'
2121
import { autoEjectCommunityTemplate } from '../util/auto-eject'
2222
import { createNitroRouteRuleMatcher } from '../util/kit'
2323
import { normaliseOptions } from '../util/options'
2424
import { useOgImageRuntimeConfig } from '../utils'
2525
import { getBrowserRenderer, getSatoriRenderer, getTakumiRenderer } from './instances'
2626

2727
const RE_HASH_MODE = /^o_([a-z0-9]+)$/i
28+
const RE_SIGNATURE_SUFFIX = /,s_([^,]+)$/
2829

2930
export function resolvePathCacheKey(e: H3Event, path: string, resolvedOptions?: Record<string, any>) {
3031
const siteConfig = getSiteConfig(e, {
@@ -64,8 +65,34 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
6465
// Hash mode: /_og/d/o_<hash>.png (for long URLs)
6566
const encodedSegment = extractEncodedSegment(path, extension)
6667

68+
// Extract and verify URL signature when signing is enabled
69+
const secret = runtimeConfig.security?.secret
70+
let paramsSegment = encodedSegment
71+
if (secret && !import.meta.dev && !import.meta.prerender) {
72+
// Extract signature (last ,s_<value> in the segment)
73+
const sigMatch = encodedSegment.match(RE_SIGNATURE_SUFFIX)
74+
if (!sigMatch) {
75+
return createError({
76+
statusCode: 403,
77+
statusMessage: '[Nuxt OG Image] Missing URL signature. Configure security.secret to sign URLs.',
78+
})
79+
}
80+
const signature = sigMatch[1]!
81+
paramsSegment = encodedSegment.slice(0, sigMatch.index!)
82+
if (!verifyOgImageSignature(paramsSegment, signature, secret!)) {
83+
return createError({
84+
statusCode: 403,
85+
statusMessage: '[Nuxt OG Image] Invalid URL signature.',
86+
})
87+
}
88+
}
89+
else if (secret && RE_SIGNATURE_SUFFIX.test(encodedSegment)) {
90+
// Strip signature in dev/prerender so it doesn't pollute decoded params
91+
paramsSegment = encodedSegment.replace(RE_SIGNATURE_SUFFIX, '')
92+
}
93+
6794
// Check for hash mode (o_<hash>)
68-
const hashMatch = encodedSegment.match(RE_HASH_MODE)
95+
const hashMatch = paramsSegment.match(RE_HASH_MODE)
6996
let urlOptions: Record<string, any> = {}
7097

7198
if (hashMatch) {
@@ -92,10 +119,10 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
92119
}
93120
}
94121
else {
95-
urlOptions = decodeOgImageParams(encodedSegment)
122+
urlOptions = decodeOgImageParams(paramsSegment)
96123
}
97124

98-
// Reject oversized query strings to limit abuse surface
125+
// Reject oversized query strings to reduce parsing overhead
99126
const maxQueryParamSize = runtimeConfig.security?.maxQueryParamSize
100127
if (maxQueryParamSize && !import.meta.prerender) {
101128
const queryString = parseURL(e.path).search || ''
@@ -107,26 +134,28 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
107134
}
108135
}
109136

110-
// Also support query params for backwards compat and dynamic overrides
111-
const query = getQuery(e)
137+
// When URL signing is active, ignore all query param overrides to prevent injection
112138
let queryParams: Record<string, any> = {}
113-
for (const k in query) {
114-
const v = String(query[k])
115-
if (!v)
116-
continue
117-
if (v.startsWith('{')) {
118-
try {
119-
queryParams[k] = JSON.parse(v)
139+
if (!secret || import.meta.dev || import.meta.prerender) {
140+
const query = getQuery(e)
141+
for (const k in query) {
142+
const v = String(query[k])
143+
if (!v)
144+
continue
145+
if (v.startsWith('{')) {
146+
try {
147+
queryParams[k] = JSON.parse(v)
148+
}
149+
catch {
150+
// ignore parse errors
151+
}
120152
}
121-
catch {
122-
// ignore parse errors
153+
else {
154+
queryParams[k] = v
123155
}
124156
}
125-
else {
126-
queryParams[k] = v
127-
}
157+
queryParams = separateProps(queryParams)
128158
}
129-
queryParams = separateProps(queryParams)
130159

131160
// basePath is used for route rules matching - can be provided via _path param
132161
const basePath = withoutTrailingSlash(urlOptions._path || '/')

src/runtime/server/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export interface GetOgImagePathResult {
1212

1313
export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOptionsInternal>): GetOgImagePathResult {
1414
const baseURL = useRuntimeConfig().app.baseURL
15-
const { defaults } = useOgImageRuntimeConfig()
15+
const { defaults, security } = useOgImageRuntimeConfig()
1616
const extension = _options?.extension || defaults.extension
1717
const isStatic = import.meta.prerender
1818
const options: Record<string, any> = { ..._options, _path: _pagePath }
@@ -24,7 +24,7 @@ export function getOgImagePath(_pagePath: string, _options?: Partial<OgImageOpti
2424
options._componentHash = component.hash
2525
// Include _path so the server knows which page to render
2626
// Pass defaults to skip encoding default values in URL
27-
const result = buildOgImageUrl(options, extension, isStatic, defaults)
27+
const result = buildOgImageUrl(options, extension, isStatic, defaults, security?.secret || undefined)
2828
return {
2929
path: joinURL('/', baseURL, result.url),
3030
hash: result.hash,

src/runtime/shared.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { defu } from 'defu'
44
import { toValue } from 'vue'
55

66
export { extractSocialPreviewTags, toBase64Image } from './pure'
7-
export { buildOgImageUrl, decodeOgImageParams, encodeOgImageParams, extractEncodedSegment, hashOgImageOptions, parseOgImageUrl } from './shared/urlEncoding'
7+
export { buildOgImageUrl, decodeOgImageParams, encodeOgImageParams, extractEncodedSegment, hashOgImageOptions, parseOgImageUrl, signEncodedParams, verifyOgImageSignature } from './shared/urlEncoding'
88

99
const RE_KEBAB_CASE = /-([a-z])/g
1010

@@ -60,7 +60,6 @@ function filterIsOgImageOption(key: string) {
6060
'alt',
6161
'props',
6262
'renderer',
63-
'html',
6463
'component',
6564
'emojis',
6665
'_query',

0 commit comments

Comments
 (0)