Skip to content

Commit cc57f82

Browse files
committed
fix: add render concurrency limits and request coalescing
Prevent DoS via concurrent render requests by adding: - Request coalescing (single-flight): concurrent requests for the same cache key share one Promise, avoiding redundant renders - Concurrency semaphore: caps parallel renders (default 3) to prevent memory exhaustion from burst traffic Both utilities are edge-runtime compatible (in-process, no shared state across isolates). Configurable via `maxConcurrentRenders` module option.
1 parent a8a65b6 commit cc57f82

File tree

5 files changed

+179
-1
lines changed

5 files changed

+179
-1
lines changed

src/module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ export interface ModuleOptions {
186186
* @example ['latin', 'latin-ext', 'devanagari']
187187
*/
188188
fontSubsets?: string[]
189+
/**
190+
* Maximum number of concurrent image renders. Prevents memory exhaustion
191+
* from burst traffic. Excess requests wait in a FIFO queue.
192+
* Works on all runtimes including edge (in-process semaphore).
193+
*
194+
* @default 3
195+
*/
196+
maxConcurrentRenders?: number
189197
/**
190198
* Browser renderer configuration.
191199
*
@@ -1365,6 +1373,7 @@ export const rootDir = ${JSON.stringify(nuxt.options.rootDir)}`
13651373
isNuxtContentDocumentDriven: !!nuxt.options.content?.documentDriven,
13661374
cacheQueryParams: config.cacheQueryParams ?? false,
13671375
cssFramework: cssFramework || 'none',
1376+
maxConcurrentRenders: config.maxConcurrentRenders ?? 3,
13681377
// Browser renderer config for cloudflare binding access
13691378
browser: typeof config.browser === 'object'
13701379
? {

src/runtime/server/util/eventHandlers.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { H3Event } from 'h3'
2+
import type { Semaphore } from './security'
23
import { getSiteConfig } from '#site-config/server/composables/getSiteConfig'
34
import { createError, H3Error, setHeader } from 'h3'
45
import { logger } from '../../logger'
@@ -8,6 +9,10 @@ import { fetchPathHtmlAndExtractOptions } from '../og-image/devtools'
89
import { html } from '../og-image/templates/html'
910
import { useOgImageRuntimeConfig } from '../utils'
1011
import { useOgImageBufferCache } from './cache'
12+
import { coalesce, createSemaphore } from './security'
13+
14+
// Lazy-init semaphore on first use (needs runtime config for limit)
15+
let renderSemaphore: Semaphore | undefined
1116

1217
export async function imageEventHandler(e: H3Event) {
1318
const ctx = await resolveContext(e).catch((err: any) => {
@@ -100,7 +105,23 @@ export async function imageEventHandler(e: H3Event) {
100105

101106
let image: H3Error | BufferSource | Buffer | Uint8Array | false | void = cacheApi.cachedItem
102107
if (!image) {
103-
image = await renderer.createImage(ctx).catch((err: any) => {
108+
// Request coalescing: if multiple requests arrive for the same key before the
109+
// first render completes, they share one Promise (single-flight pattern).
110+
// Concurrency semaphore: limits parallel renders to prevent memory exhaustion.
111+
// Both are process-local and edge-compatible (no shared state needed).
112+
image = await coalesce(ctx.key, async () => {
113+
const runtimeCfg = useOgImageRuntimeConfig()
114+
if (!renderSemaphore)
115+
renderSemaphore = createSemaphore(runtimeCfg.maxConcurrentRenders || 3)
116+
117+
await renderSemaphore.acquire()
118+
try {
119+
return await renderer.createImage(ctx)
120+
}
121+
finally {
122+
renderSemaphore.release()
123+
}
124+
}).catch((err: any) => {
104125
logger.error(`renderer.createImage error for ${e.path}:`, err?.stack || err?.message || err)
105126
throw err
106127
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Security utilities for OG image generation.
3+
*
4+
* Request coalescing (single-flight) and concurrency semaphore.
5+
* All utilities are edge-runtime compatible (no shared state across isolates).
6+
*/
7+
8+
// ---------------------------------------------------------------------------
9+
// Request coalescing (single-flight)
10+
// ---------------------------------------------------------------------------
11+
12+
const inFlight = new Map<string, Promise<any>>()
13+
14+
/**
15+
* Ensures only one render executes per cache key at a time. Concurrent requests
16+
* for the same key share the same Promise. Process-local, edge-compatible.
17+
*/
18+
export function coalesce<T>(key: string, fn: () => Promise<T>): Promise<T> {
19+
const existing = inFlight.get(key)
20+
if (existing)
21+
return existing as Promise<T>
22+
const promise = fn().finally(() => inFlight.delete(key))
23+
inFlight.set(key, promise)
24+
return promise
25+
}
26+
27+
// ---------------------------------------------------------------------------
28+
// Concurrency semaphore
29+
// ---------------------------------------------------------------------------
30+
31+
export type Semaphore = ReturnType<typeof createSemaphore>
32+
33+
/**
34+
* Creates a simple in-process semaphore. Edge-compatible (no shared state).
35+
* When the limit is reached, new callers wait in a FIFO queue.
36+
*/
37+
export function createSemaphore(limit: number) {
38+
let active = 0
39+
const queue: Array<() => void> = []
40+
41+
function release() {
42+
active--
43+
const next = queue.shift()
44+
if (next)
45+
next()
46+
}
47+
48+
return {
49+
async acquire(): Promise<void> {
50+
if (active < limit) {
51+
active++
52+
return
53+
}
54+
return new Promise<void>((resolve) => {
55+
queue.push(() => {
56+
active++
57+
resolve()
58+
})
59+
})
60+
},
61+
release,
62+
/** Number of renders currently executing */
63+
get active() { return active },
64+
/** Number of requests waiting */
65+
get waiting() { return queue.length },
66+
}
67+
}

src/runtime/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export interface OgImageRuntimeConfig {
5959
/** Path to community templates (dev only) */
6060
communityTemplatesDir?: string
6161

62+
/** Maximum concurrent image renders (edge-compatible in-process semaphore) */
63+
maxConcurrentRenders: number
64+
6265
/** Browser renderer config for cloudflare binding access */
6366
browser?: {
6467
provider?: BrowserProvider

test/unit/security.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { coalesce, createSemaphore } from '../../src/runtime/server/util/security'
3+
4+
describe('coalesce', () => {
5+
it('deduplicates concurrent calls with the same key', async () => {
6+
let callCount = 0
7+
const fn = () => {
8+
callCount++
9+
return new Promise<string>(resolve => setTimeout(resolve, 10, 'result'))
10+
}
11+
const [a, b, c] = await Promise.all([
12+
coalesce('key1', fn),
13+
coalesce('key1', fn),
14+
coalesce('key1', fn),
15+
])
16+
expect(callCount).toBe(1)
17+
expect(a).toBe('result')
18+
expect(b).toBe('result')
19+
expect(c).toBe('result')
20+
})
21+
22+
it('allows different keys to run independently', async () => {
23+
let callCount = 0
24+
const fn = () => {
25+
callCount++
26+
return Promise.resolve('ok')
27+
}
28+
await Promise.all([coalesce('a', fn), coalesce('b', fn)])
29+
expect(callCount).toBe(2)
30+
})
31+
32+
it('cleans up after completion so subsequent calls re-execute', async () => {
33+
let callCount = 0
34+
const fn = () => {
35+
callCount++
36+
return Promise.resolve(callCount)
37+
}
38+
const first = await coalesce('x', fn)
39+
const second = await coalesce('x', fn)
40+
expect(first).toBe(1)
41+
expect(second).toBe(2)
42+
})
43+
})
44+
45+
describe('createSemaphore', () => {
46+
it('limits concurrent execution', async () => {
47+
const sem = createSemaphore(2)
48+
let running = 0
49+
let maxRunning = 0
50+
51+
const task = async () => {
52+
await sem.acquire()
53+
running++
54+
maxRunning = Math.max(maxRunning, running)
55+
await new Promise(r => setTimeout(r, 20))
56+
running--
57+
sem.release()
58+
}
59+
60+
await Promise.all([task(), task(), task(), task(), task()])
61+
expect(maxRunning).toBe(2)
62+
})
63+
64+
it('reports active and waiting counts', async () => {
65+
const sem = createSemaphore(1)
66+
await sem.acquire()
67+
expect(sem.active).toBe(1)
68+
69+
const waiting = sem.acquire()
70+
expect(sem.waiting).toBe(1)
71+
72+
sem.release()
73+
await waiting
74+
expect(sem.active).toBe(1)
75+
expect(sem.waiting).toBe(0)
76+
sem.release()
77+
})
78+
})

0 commit comments

Comments
 (0)