Skip to content

Commit 25c057b

Browse files
authored
fix(security): strict mode, deprecate html (#545)
1 parent 87a2539 commit 25c057b

File tree

9 files changed

+137
-7
lines changed

9 files changed

+137
-7
lines changed

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,30 @@ For full protection, we recommend combining URL signing with a **web application
1313
export default defineNuxtConfig({
1414
ogImage: {
1515
security: {
16+
strict: true,
1617
secret: process.env.OG_IMAGE_SECRET,
1718
}
1819
}
1920
})
2021
```
2122

23+
## Strict Mode
24+
25+
Enabling `strict` mode applies all recommended security defaults in a single flag:
26+
27+
- **URL signing required**: `secret` must be set (rejects unsigned runtime requests with `403`)
28+
- **Inline HTML disabled**: The deprecated `html` option is stripped entirely, preventing SSRF via inline HTML injection
29+
- **Query string size limit**: `maxQueryParamSize` defaults to `2048` characters (instead of no limit)
30+
- **Origin restriction**: `restrictRuntimeImagesToOrigin` defaults to `true`, locking runtime generation to your site config URL host
31+
32+
Any of these can still be overridden explicitly. Strict mode only changes the defaults.
33+
34+
The build will fail if `strict` is enabled without a `secret`. Generate one with:
35+
36+
```bash
37+
npx nuxt-og-image generate-secret
38+
```
39+
2240
## URL Signing
2341

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

docs/content/4.api/0.define-og-image.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ The emoji set to use when generating the image. Set to `false` to disable emoji
7070

7171
### `html`
7272

73+
::deprecated
74+
The `html` option is deprecated and will be removed in the next major version due to SSRF risk. Use a Vue component instead. This option is disabled when `security.strict` is enabled.
75+
::
76+
7377
- Type: `string`{lang="ts"}
7478
- Default: `undefined`{lang="ts"}
7579

@@ -186,6 +190,10 @@ defineOgImage('MyCustomComponent.takumi', { title: 'Hello' })
186190

187191
### Inline HTML Templates
188192

193+
::deprecated
194+
The inline HTML templates feature is deprecated and will be removed in the next major version. Use a Vue component instead.
195+
::
196+
189197
If you have a simple template and prefer to inline it, you can do so using the `html` option:
190198

191199
```ts

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,17 +186,14 @@ Security limits for image generation. See the [Security Guide](/docs/og-image/gu
186186
- **`renderTimeout`**: Milliseconds before the render is aborted with a `408` response. Default `15000`.
187187
- **`maxQueryParamSize`**: Maximum query string length (in characters) for runtime requests. Returns `400` when exceeded. Default `null` (no limit).
188188
- **`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.
189+
- **`strict`**: Enable strict security mode. Requires `secret`, disables the deprecated `html` option, defaults `maxQueryParamSize` to `2048`, and enables `restrictRuntimeImagesToOrigin`. Default `false`. See the [Security Guide](/docs/og-image/guides/security#strict-mode) for details.
189190

190191
```ts [nuxt.config.ts]
191192
export default defineNuxtConfig({
192193
ogImage: {
193194
security: {
195+
strict: true,
194196
secret: process.env.OG_IMAGE_SECRET,
195-
maxDimension: 2048,
196-
maxDpr: 2,
197-
renderTimeout: 15000,
198-
restrictRuntimeImagesToOrigin: true, // lock to site config URL host
199-
// or: ['https://cdn.example.com'] // allow additional hosts
200197
}
201198
}
202199
})

src/module.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,20 @@ export interface ModuleOptions {
235235
*
236236
* Must be a stable string across deployments. Generate one with:
237237
* `npx nuxt-og-image generate-secret`
238+
*
239+
* Required when `strict` is enabled.
238240
*/
239241
secret?: string
242+
/**
243+
* Enable strict security mode. When enabled:
244+
* - `secret` is required (URL signing)
245+
* - The `html` option is disabled (prevents SSRF via inline HTML injection)
246+
* - `maxQueryParamSize` defaults to `2048`
247+
* - `restrictRuntimeImagesToOrigin` defaults to `true`
248+
*
249+
* @default false
250+
*/
251+
strict?: boolean
240252
}
241253
}
242254

@@ -337,6 +349,10 @@ export default defineNuxtModule<ModuleOptions>({
337349
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.')
338350
}
339351

352+
if (config.security?.strict && !config.security?.secret) {
353+
throw new Error('[nuxt-og-image] `security.strict` requires `security.secret` to be set. Generate one with: npx nuxt-og-image generate-secret')
354+
}
355+
340356
if (nuxt.options.dev && !config.zeroRuntime && !config.security?.secret) {
341357
logger.warn([
342358
'OG image URLs are not signed. Anyone can craft arbitrary image generation requests.',
@@ -1444,11 +1460,12 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
14441460
}
14451461
: undefined,
14461462
security: {
1463+
strict: config.security?.strict ?? false,
14471464
maxDimension: config.security?.maxDimension ?? 2048,
14481465
maxDpr: config.security?.maxDpr ?? 2,
14491466
renderTimeout: config.security?.renderTimeout ?? 15_000,
1450-
maxQueryParamSize: config.security?.maxQueryParamSize ?? null,
1451-
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin === true
1467+
maxQueryParamSize: config.security?.maxQueryParamSize ?? (config.security?.strict ? 2048 : null),
1468+
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin === true || (config.security?.strict && config.security?.restrictRuntimeImagesToOrigin == null)
14521469
? []
14531470
: (config.security?.restrictRuntimeImagesToOrigin || false),
14541471
secret: config.security?.secret || '',

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getNitroOrigin } from '#site-config/server/composables'
55
import { withQuery } from 'ufo'
66
import { toValue } from 'vue'
77
import { buildOgImageUrl } from '../../../shared'
8+
import { logger } from '../../util/logger'
89
import { useOgImageRuntimeConfig } from '../../utils'
910

1011
// Detect if we're using Playwright or Puppeteer
@@ -104,6 +105,9 @@ export async function createScreenshot({ basePath, e, options, extension }: OgIm
104105
}
105106

106107
try {
108+
if (options.html) {
109+
logger.warn('The `html` option is deprecated and will be removed in the next major version. Use a Vue component instead.')
110+
}
107111
if (import.meta.prerender && !options.html) {
108112
// we need to do a nitro fetch for the HTML instead of rendering with browser
109113
options.html = await e.$fetch(path).catch(() => undefined) as string

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
154154
queryParams[k] = v
155155
}
156156
}
157+
// Always strip html from query params before separation to prevent SSRF via inline HTML injection
158+
delete queryParams.html
157159
queryParams = separateProps(queryParams)
158160
}
159161

@@ -163,6 +165,10 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
163165
delete urlOptions._path
164166
delete urlOptions._hash // Remove internal hash field
165167
delete urlOptions._componentHash // Not needed for rendering
168+
// In strict mode, strip html from URL options (disables the feature entirely)
169+
if (runtimeConfig.security?.strict) {
170+
delete urlOptions.html
171+
}
166172

167173
const basePathWithQuery = queryParams._query && typeof queryParams._query === 'object'
168174
? withQuery(basePath, queryParams._query)

src/runtime/server/og-image/core/vnodes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ export interface CreateVNodesOptions {
225225

226226
export async function createVNodes(ctx: OgImageRenderEventContext, options?: CreateVNodesOptions): Promise<VNode> {
227227
let html = ctx.options.html
228+
if (html) {
229+
logger.warn('The `html` option is deprecated and will be removed in the next major version. Use a Vue component instead.')
230+
}
228231
if (!html) {
229232
const island = await fetchIsland(ctx.e, ctx.options.component!, typeof ctx.options.props !== 'undefined' ? ctx.options.props as Record<string, any> : ctx.options)
230233
// this fixes any inline style props that need to be wrapped in single quotes, such as:

src/runtime/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface OgImageRuntimeConfig {
7272
maxQueryParamSize: number | null
7373
restrictRuntimeImagesToOrigin: false | string[]
7474
secret: string
75+
strict: boolean
7576
}
7677

7778
app: {
@@ -159,6 +160,9 @@ export interface OgImageOptions {
159160
emojis?: IconifyEmojiIconSets | false
160161
/**
161162
* Provide a static HTML template to render the OG Image instead of a component.
163+
*
164+
* @deprecated The `html` option will be removed in the next major version due to SSRF risk.
165+
* Use a Vue component instead. Disabled when `security.strict` is enabled.
162166
*/
163167
html?: string
164168
// vendor config

test/unit/strict-mode.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
/**
4+
* Tests for security.strict mode config resolution.
5+
* Mirrors the logic in module.ts for resolving security defaults.
6+
*/
7+
function resolveSecurityConfig(config?: {
8+
strict?: boolean
9+
secret?: string
10+
maxQueryParamSize?: number | null
11+
restrictRuntimeImagesToOrigin?: boolean | string[]
12+
}) {
13+
return {
14+
strict: config?.strict ?? false,
15+
maxDimension: 2048,
16+
maxDpr: 2,
17+
renderTimeout: 15_000,
18+
maxQueryParamSize: config?.maxQueryParamSize ?? (config?.strict ? 2048 : null),
19+
restrictRuntimeImagesToOrigin: config?.restrictRuntimeImagesToOrigin === true || (config?.strict && config?.restrictRuntimeImagesToOrigin == null)
20+
? []
21+
: (config?.restrictRuntimeImagesToOrigin || false),
22+
secret: config?.secret || '',
23+
}
24+
}
25+
26+
describe('security.strict mode defaults', () => {
27+
it('defaults to non-strict with no limits', () => {
28+
const result = resolveSecurityConfig()
29+
expect(result.strict).toBe(false)
30+
expect(result.maxQueryParamSize).toBeNull()
31+
expect(result.restrictRuntimeImagesToOrigin).toBe(false)
32+
})
33+
34+
it('strict mode sets maxQueryParamSize to 2048', () => {
35+
const result = resolveSecurityConfig({ strict: true, secret: 'test' })
36+
expect(result.maxQueryParamSize).toBe(2048)
37+
})
38+
39+
it('strict mode enables restrictRuntimeImagesToOrigin', () => {
40+
const result = resolveSecurityConfig({ strict: true, secret: 'test' })
41+
expect(result.restrictRuntimeImagesToOrigin).toEqual([])
42+
})
43+
44+
it('explicit maxQueryParamSize overrides strict default', () => {
45+
const result = resolveSecurityConfig({ strict: true, secret: 'test', maxQueryParamSize: 4096 })
46+
expect(result.maxQueryParamSize).toBe(4096)
47+
})
48+
49+
it('explicit restrictRuntimeImagesToOrigin array overrides strict default', () => {
50+
const result = resolveSecurityConfig({
51+
strict: true,
52+
secret: 'test',
53+
restrictRuntimeImagesToOrigin: ['https://cdn.example.com'],
54+
})
55+
expect(result.restrictRuntimeImagesToOrigin).toEqual(['https://cdn.example.com'])
56+
})
57+
58+
it('explicit restrictRuntimeImagesToOrigin false overrides strict default', () => {
59+
const result = resolveSecurityConfig({
60+
strict: true,
61+
secret: 'test',
62+
restrictRuntimeImagesToOrigin: false,
63+
})
64+
expect(result.restrictRuntimeImagesToOrigin).toBe(false)
65+
})
66+
67+
it('strict mode requires secret (validated at module setup)', () => {
68+
// Module setup throws when strict is true and secret is missing.
69+
// This test documents the contract; the actual throw is in module.ts setup().
70+
const config = { strict: true }
71+
expect(config.strict && !config.secret).toBe(true)
72+
})
73+
})

0 commit comments

Comments
 (0)