Skip to content

feat(security): add URL signing to prevent parameter tampering#546

Merged
harlan-zw merged 7 commits intomainfrom
feat/hmac-url-signing
Mar 28, 2026
Merged

feat(security): add URL signing to prevent parameter tampering#546
harlan-zw merged 7 commits intomainfrom
feat/hmac-url-signing

Conversation

@harlan-zw
Copy link
Copy Markdown
Collaborator

@harlan-zw harlan-zw commented Mar 28, 2026

🔗 Linked issue

Related to GHSA-pqhr-mp3f-hrpp, GHSA-mg36-wvcr-m75h, GHSA-c7xp-q6q8-hg76

❓ Type of change

  • 📖 Documentation
  • 🐞 Bug fix
  • 👌 Enhancement
  • ✨ New feature
  • 🧹 Chore
  • ⚠️ Breaking 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.secret is 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.secret config option with runtime config passthrough
  • signEncodedParams() / verifyOgImageSignature() using ohash (cross-runtime compatible, synchronous)
  • Signature embedded in encoded path segment (CDN-cache-friendly, no query string)
  • Query params ignored when signing is active
  • npx nuxt-og-image generate-secret CLI command
  • Dev-only warning when neither zeroRuntime nor secret is configured
  • Security guide updated: clarifies DoS vector, recommends WAF/rate limiting for full protection
  • 24 unit tests covering signing, verification, round-trips, and edge cases

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 28, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nuxt-og-image@546

commit: b8fc61c

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.secret to 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-secret CLI 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 html from KNOWN_PARAMS means decodeOgImageParams() will now treat html_<...> as a component prop instead of the documented OgImageOptions.html field (which still exists in src/runtime/types.ts). This is a breaking behavioral change and also means the server-side delete urlOptions.html no longer strips injected HTML attempts. Either keep html as a known option (and handle its security separately) or fully remove/rename the html option 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.html won’t actually strip user-supplied html from incoming requests anymore, because separateProps() and decodeOgImageParams() now classify html as a prop/unknown param (it ends up under props.html). If the goal is to fully disallow passing html via URL/query, you’ll need to strip it from props as well (or restore html as 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 treats html as an OG image option, but OgImageOptions still exposes html?: string and runtime code (e.g. browser screenshot flow) references options.html. This change will push html into props instead, which is a breaking behavioral change. Either keep html in this allowlist or remove the html option 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.

Comment on lines +422 to +428
/**
* 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)
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +413 to 419
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}`,
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
})
}
}
else if (RE_SIGNATURE_SUFFIX.test(encodedSegment)) {
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
else if (RE_SIGNATURE_SUFFIX.test(encodedSegment)) {
else if (secret && RE_SIGNATURE_SUFFIX.test(encodedSegment)) {

Copilot uses AI. Check for mistakes.
Comment on lines +125 to +135
// 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.`,
})
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// 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.`,
})

Copilot uses AI. Check for mistakes.
src/module.ts Outdated
Comment on lines +339 to +351
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'))
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +58
```

## 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
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +25
## 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.

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use “DoS” (denial-of-service) rather than “DOS”.

Copilot uses AI. Check for mistakes.
Comment on lines +1664 to +1682
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('')
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 390 to +443
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
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +88
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.',
})
}
}
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
- 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
@harlan-zw harlan-zw changed the title feat(security): add HMAC URL signing to prevent parameter tampering feat(security): add URL signing to prevent parameter tampering Mar 28, 2026
@harlan-zw harlan-zw merged commit 87a2539 into main Mar 28, 2026
10 checks passed
@harlan-zw harlan-zw deleted the feat/hmac-url-signing branch March 28, 2026 06:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants