feat(security): add URL signing to prevent parameter tampering#546
feat(security): add URL signing to prevent parameter tampering#546
Conversation
commit: |
There was a problem hiding this comment.
Pull request overview
Adds an opt-in URL signing mechanism for runtime OG image endpoints, intended to prevent parameter tampering by embedding a signature in the encoded path segment and enforcing signature verification server-side when security.secret is configured.
Changes:
- Adds
security.secretto runtime/module config and wires it into OG URL generation. - Appends a
,s_<signature>suffix to encoded OG image URL path segments and verifies it in the runtime handler (with query overrides disabled when signing is active). - Introduces a
generate-secretCLI command and updates security/config documentation.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/runtime/types.ts | Adds security.secret to runtime config typing. |
| src/runtime/shared/urlEncoding.ts | Implements signature suffixing + sign/verify helpers and updates URL builder. |
| src/runtime/shared.ts | Re-exports signing helpers; adjusts option filtering. |
| src/runtime/server/utils.ts | Passes security.secret into URL generation on the server utility path. |
| src/runtime/server/og-image/context.ts | Verifies/strips signature in request handler; ignores query overrides when signing is active. |
| src/runtime/app/utils.ts | Passes security.secret into URL generation in app-side SSR utility path. |
| src/module.ts | Documents security.secret, warns when unsigned, and serializes runtime config. |
| src/cli.ts | Adds generate-secret command for creating signing secrets. |
| docs/content/4.api/3.config.md | Documents security.secret and notes interaction with maxQueryParamSize. |
| docs/content/3.guides/13.security.md | Adds URL signing guide content and updates query-string guidance. |
Comments suppressed due to low confidence (3)
src/runtime/shared/urlEncoding.ts:84
- Removing
htmlfromKNOWN_PARAMSmeansdecodeOgImageParams()will now treathtml_<...>as a component prop instead of the documentedOgImageOptions.htmlfield (which still exists insrc/runtime/types.ts). This is a breaking behavioral change and also means the server-sidedelete urlOptions.htmlno longer strips injected HTML attempts. Either keephtmlas a known option (and handle its security separately) or fully remove/rename thehtmloption across types, encoding/decoding, and server handling.
// Known OgImageOptions keys (not component props)
const KNOWN_PARAMS = new Set([
'width',
'height',
'component',
'renderer',
'emojis',
'key',
'alt',
'url',
'cacheMaxAgeSeconds',
'cacheKey',
'extension',
'satori',
'resvg',
'sharp',
'screenshot',
'takumi',
'fonts',
'_query',
'_hash',
'_componentHash',
'socialPreview',
'props',
'_path',
])
src/runtime/server/og-image/context.ts:180
delete queryParams.html/delete urlOptions.htmlwon’t actually strip user-suppliedhtmlfrom incoming requests anymore, becauseseparateProps()anddecodeOgImageParams()now classifyhtmlas a prop/unknown param (it ends up underprops.html). If the goal is to fully disallow passinghtmlvia URL/query, you’ll need to strip it frompropsas well (or restorehtmlas a known option and explicitly drop it earlier).
const options = defu(queryParams, urlOptions, ogImageRouteRules, runtimeConfig.defaults) as OgImageOptionsInternal
// Clamp dimensions to prevent DoS via oversized image generation
src/runtime/shared.ts:66
filterIsOgImageOption()no longer treatshtmlas an OG image option, butOgImageOptionsstill exposeshtml?: stringand runtime code (e.g. browser screenshot flow) referencesoptions.html. This change will pushhtmlintopropsinstead, which is a breaking behavioral change. Either keephtmlin this allowlist or remove thehtmloption consistently across types/docs/runtime and enforce the new behavior explicitly.
function filterIsOgImageOption(key: string) {
const keys: (keyof OgImageOptionsInternal)[] = [
'url',
'extension',
'width',
'height',
'alt',
'props',
'renderer',
'component',
'emojis',
'_query',
'_hash',
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Sign encoded params using ohash (SHA-256 based, cross-runtime compatible). | ||
| * Returns first 16 chars of the base64url hash for URL brevity. | ||
| */ | ||
| export function signEncodedParams(encoded: string, secret: string): string { | ||
| return hash(`${secret}:${encoded}`).slice(0, 16) | ||
| } |
There was a problem hiding this comment.
signEncodedParams() is described as “HMAC … SHA-256 based”, but it currently uses ohash.hash(secret + ':' + encoded) which is not an HMAC and (given ohash usage elsewhere in this repo for cache keys) is very likely not a cryptographic MAC. This makes the signature forgeable/collision-prone and the docs/comments misleading. Use a real HMAC (e.g., HMAC-SHA-256 via WebCrypto or a vetted sync implementation like @noble/hashes) and update the comment accordingly.
| const segment = encoded || 'default' | ||
| // Sign dynamic URLs only (static/prerendered are served as files, no runtime verification) | ||
| const signed = secret && !isStatic ? `${segment},s_${signEncodedParams(segment, secret)}` : segment | ||
|
|
||
| return { | ||
| url: encoded ? `${prefix}/${encoded}.${extension}` : `${prefix}/default.${extension}`, | ||
| url: `${prefix}/${signed}.${extension}`, | ||
| } |
There was a problem hiding this comment.
When signing is enabled, the URL contains a trailing ,s_<sig> token, but parseOgImageUrl() will currently decode that as an extra option/prop (since it doesn’t strip the signature suffix the way the server does). If parseOgImageUrl() is part of the public API, it should remove the signature segment before calling decodeOgImageParams() so callers get a clean options object.
| }) | ||
| } | ||
| } | ||
| else if (RE_SIGNATURE_SUFFIX.test(encodedSegment)) { |
There was a problem hiding this comment.
RE_SIGNATURE_SUFFIX is stripped even when security.secret is not configured. That means any legitimate URL whose last encoded token happens to look like ,s_<value> (e.g. a component prop named s) will get truncated and decoded incorrectly. Only strip/verify a signature suffix when signing is actually enabled (i.e. when a non-empty secret is configured).
| else if (RE_SIGNATURE_SUFFIX.test(encodedSegment)) { | |
| else if (secret && RE_SIGNATURE_SUFFIX.test(encodedSegment)) { |
| // Reject oversized query strings (skipped when signing is active since query params are ignored) | ||
| if (!secret) { | ||
| 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.`, | ||
| }) | ||
| } |
There was a problem hiding this comment.
maxQueryParamSize is skipped entirely when secret is set, but an attacker can still send an extremely large query string even if it’s ignored during option resolution. Keeping a URI/query length guard can still be valuable to reduce request parsing overhead and protect upstreams/CDNs from request-line DoS. Consider enforcing the size limit regardless of signing (or introducing a separate max-URL-length guard that always applies).
| // Reject oversized query strings (skipped when signing is active since query params are ignored) | |
| if (!secret) { | |
| 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.`, | |
| }) | |
| } | |
| // Reject oversized query strings to reduce parsing overhead and guard against request-line DoS | |
| 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.`, | |
| }) |
src/module.ts
Outdated
| if (!config.zeroRuntime && !config.security?.secret) { | ||
| logger.warn([ | ||
| 'OG image URLs are not signed. Anyone can craft arbitrary image generation requests.', | ||
| '', | ||
| 'Either set a signing secret:', | ||
| ' ogImage: { security: { secret: process.env.OG_IMAGE_SECRET } }', | ||
| '', | ||
| ' Generate one with: npx nuxt-og-image generate-secret', | ||
| '', | ||
| 'Or enable zero-runtime mode to disable dynamic generation entirely:', | ||
| ' ogImage: { zeroRuntime: true }', | ||
| ].join('\n')) | ||
| } |
There was a problem hiding this comment.
The PR description mentions a dev warning when neither zeroRuntime nor security.secret is configured, but this warning currently triggers in all modes (including production builds). If the intent is dev-only guidance, gate this behind nuxt.options.dev (or adjust the PR description/docs to reflect that it also warns in production).
docs/content/3.guides/13.security.md
Outdated
| ``` | ||
|
|
||
| ## URL Signing | ||
|
|
||
| When a signing secret is configured, every OG image URL includes an HMAC signature in the path. The server verifies this signature before rendering, rejecting any URL that has been tampered with or crafted manually. | ||
|
|
||
| This prevents unauthorized image generation requests that would otherwise consume server resources. | ||
|
|
||
| ### Setup | ||
|
|
||
| 1. Generate a secret: | ||
|
|
||
| ```bash | ||
| npx nuxt-og-image generate-secret | ||
| ``` | ||
|
|
||
| 2. Set the environment variable and reference it in your config: | ||
|
|
||
| ```ts [nuxt.config.ts] | ||
| export default defineNuxtConfig({ | ||
| ogImage: { | ||
| security: { | ||
| secret: process.env.OG_IMAGE_SECRET, | ||
| } | ||
| } | ||
| }) | ||
| ``` | ||
|
|
||
| ### How It Works | ||
|
|
||
| When a secret is configured: | ||
| - `defineOgImage()`{lang="ts"} appends a signature to the URL path: `/_og/d/w_1200,h_600,s_abc123def456.png` | ||
| - The server extracts and verifies the signature before processing the request | ||
| - Requests with missing or invalid signatures receive a `403` response | ||
| - All query parameter overrides are ignored (the signed path is the single source of truth) | ||
|
|
||
| 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. | ||
|
|
||
| ### What It Supersedes |
There was a problem hiding this comment.
This guide repeatedly refers to an “HMAC signature” and positions it as protection against DoS, but the current code path uses ohash.hash(secret + ':' + encoded) (not an HMAC) and also explicitly disables maxQueryParamSize when secret is set. Please align the guide with the actual implementation, or update the implementation to match the documented security guarantees.
| ## URL Signing | ||
|
|
||
| When a signing secret is configured, every OG image URL includes an HMAC signature in the path. The server verifies this signature before rendering, rejecting any URL that has been tampered with or crafted manually. | ||
|
|
There was a problem hiding this comment.
Use “DoS” (denial-of-service) rather than “DOS”.
| function generateSecret() { | ||
| const secret = randomBytes(32).toString('hex') | ||
| p.intro('nuxt-og-image generate-secret') | ||
| p.note([ | ||
| `${secret}`, | ||
| '', | ||
| 'Add this to your nuxt.config.ts:', | ||
| '', | ||
| ' ogImage: {', | ||
| ' security: {', | ||
| ` secret: process.env.OG_IMAGE_SECRET,`, | ||
| ' }', | ||
| ' }', | ||
| '', | ||
| 'Then set the environment variable:', | ||
| ` OG_IMAGE_SECRET=${secret}`, | ||
| ].join('\n'), 'Generated Secret') | ||
| p.outro('') | ||
| } |
There was a problem hiding this comment.
The new generate-secret command isn’t covered by the existing CLI unit tests (test/unit/cli.test.ts has coverage for other commands). Adding a test would help prevent regressions (e.g. command routing, output format, and ensuring it generates a suitably long random secret).
| export function buildOgImageUrl( | ||
| options: Record<string, any>, | ||
| extension: string = 'png', | ||
| isStatic: boolean = false, | ||
| defaults?: Record<string, any>, | ||
| secret?: string, | ||
| ): BuildOgImageUrlResult { | ||
| const encoded = encodeOgImageParams(options, defaults) | ||
| const prefix = isStatic ? '/_og/s' : '/_og/d' | ||
|
|
||
| // Check if encoded path is too long or contains percent-encoded chars (only applies to static/prerendered) | ||
| // Hash mode requires prerender options cache, so it can't work at runtime | ||
| // Percent-encoded chars (%23=#, %3F=?, %2C=, etc.) get decoded by prerender crawlers, | ||
| // proxies, and CDNs in unpredictable ways — hash mode avoids this entirely | ||
| if (isStatic && (encoded.length > MAX_PATH_LENGTH || encoded.includes('%'))) { | ||
| // Use hash mode - short deterministic path | ||
| const hash = hashOgImageOptions(options) | ||
| return { | ||
| url: `${prefix}/o_${hash}.${extension}`, | ||
| hash, | ||
| } | ||
| } | ||
|
|
||
| const segment = encoded || 'default' | ||
| // Sign dynamic URLs only (static/prerendered are served as files, no runtime verification) | ||
| const signed = secret && !isStatic ? `${segment},s_${signEncodedParams(segment, secret)}` : segment | ||
|
|
||
| return { | ||
| url: encoded ? `${prefix}/${encoded}.${extension}` : `${prefix}/default.${extension}`, | ||
| url: `${prefix}/${signed}.${extension}`, | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Sign encoded params using ohash (SHA-256 based, cross-runtime compatible). | ||
| * Returns first 16 chars of the base64url hash for URL brevity. | ||
| */ | ||
| export function signEncodedParams(encoded: string, secret: string): string { | ||
| return hash(`${secret}:${encoded}`).slice(0, 16) | ||
| } | ||
|
|
||
| /** | ||
| * Verify a signature against encoded params. | ||
| * Uses constant-time string comparison to prevent timing attacks. | ||
| */ | ||
| export function verifyOgImageSignature(encoded: string, signature: string, secret: string): boolean { | ||
| const expected = signEncodedParams(encoded, secret) | ||
| if (expected.length !== signature.length) | ||
| return false | ||
| // constant-time comparison | ||
| let result = 0 | ||
| for (let i = 0; i < expected.length; i++) | ||
| result |= expected.charCodeAt(i) ^ signature.charCodeAt(i) | ||
| return result === 0 | ||
| } |
There was a problem hiding this comment.
URL signing introduces new behavior in buildOgImageUrl() (signature suffix) and new signEncodedParams() / verifyOgImageSignature() helpers, but there are no corresponding unit tests (e.g. ensuring signatures are deterministic, verifying/denying tampered segments, and ensuring signature stripping doesn’t affect decoding). Given the extensive existing test/unit/urlEncoding.test.ts, this should be covered to avoid subtle security regressions.
| if (secret && !import.meta.dev && !import.meta.prerender) { | ||
| // Extract signature (last ,s_<value> in the segment) | ||
| const sigMatch = encodedSegment.match(RE_SIGNATURE_SUFFIX) | ||
| if (!sigMatch) { | ||
| return createError({ | ||
| statusCode: 403, | ||
| statusMessage: '[Nuxt OG Image] Missing URL signature. Configure security.secret to sign URLs.', | ||
| }) | ||
| } | ||
| const signature = sigMatch[1]! | ||
| paramsSegment = encodedSegment.slice(0, sigMatch.index!) | ||
| if (!verifyOgImageSignature(paramsSegment, signature, secret!)) { | ||
| return createError({ | ||
| statusCode: 403, | ||
| statusMessage: '[Nuxt OG Image] Invalid URL signature.', | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
With security.secret enabled, this handler rejects unsigned requests with 403. There are internal runtime codepaths that generate OG URLs (e.g. browser screenshot renderer loads an .html OG route) and not all call sites have been updated to pass the secret into buildOgImageUrl(). As-is, enabling security.secret is likely to break those internal fetches with “Missing URL signature”. Ensure every internal buildOgImageUrl(...) call that hits the runtime handler includes the secret (or exempt internal subrequests in a safer way).
- Use "keyed hash" instead of "HMAC" in docs and comments - Strip signature only when secret is configured (fixes prop named "s" conflict) - Keep maxQueryParamSize check regardless of signing (reduces parsing overhead) - Gate dev warning behind nuxt.options.dev - Pass secret to browser renderer's internal buildOgImageUrl call - Strip signature in parseOgImageUrl for clean option parsing - Don't sign static/prerender URLs (no runtime verification) - Fix DoS casing in docs - Add 24 unit tests covering signing, verification, round-trips, and edge cases
🔗 Linked issue
Related to GHSA-pqhr-mp3f-hrpp, GHSA-mg36-wvcr-m75h, GHSA-c7xp-q6q8-hg76
❓ Type of change
📚 Description
The primary security concern with runtime OG image generation is DoS: without protection, anyone can craft arbitrary image generation URLs to consume server CPU and memory. This adds opt-in URL signing to ensure only application-generated URLs are accepted.
When
security.secretis configured, every generated URL includes a keyed hash signature in the path (s_<sig>). The server verifies the signature before rendering, rejecting tampered or manually crafted URLs with 403.Key changes:
security.secretconfig option with runtime config passthroughsignEncodedParams()/verifyOgImageSignature()usingohash(cross-runtime compatible, synchronous)npx nuxt-og-image generate-secretCLI commandzeroRuntimenorsecretis configured