Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ catalog:
mocked-exports: ^0.1.1
nuxt: ^4.4.2
nuxt-site-config: ^4.0.6
nuxtseo-layer-devtools: ^0.5.0
nuxtseo-shared: ^0.8.2
nuxtseo-layer-devtools: ^0.5.1
nuxtseo-shared: ^0.9.0
nypm: ^0.6.5
ofetch: ^1.5.1
ohash: ^2.0.11
Expand Down
6 changes: 5 additions & 1 deletion src/runtime/server/og-image/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { useNitroApp } from 'nitropack/runtime'
import { hash } from 'ohash'
import { parseURL, withoutLeadingSlash, withoutTrailingSlash, withQuery } from 'ufo'
import { normalizeKey } from 'unstorage'
import { decodeOgImageParams, extractEncodedSegment, separateProps } from '../../shared'
import { decodeOgImageParams, extractEncodedSegment, sanitizeProps, separateProps } from '../../shared'
import { autoEjectCommunityTemplate } from '../util/auto-eject'
import { createNitroRouteRuleMatcher } from '../util/kit'
import { normaliseOptions } from '../util/options'
Expand Down Expand Up @@ -131,6 +131,10 @@ export async function resolveContext(e: H3Event): Promise<H3Error | OgImageRende
const ogImageRouteRules = separateProps(routeRules.ogImage as RouteRulesOgImage)
const options = defu(queryParams, urlOptions, ogImageRouteRules, runtimeConfig.defaults) as OgImageOptionsInternal

// Strip HTML event handlers and dangerous attributes from props (GHSA-mg36-wvcr-m75h)
if (options.props && typeof options.props === 'object')
options.props = sanitizeProps(options.props as Record<string, any>)

if (!options) {
return createError({
statusCode: 404,
Expand Down
149 changes: 149 additions & 0 deletions src/runtime/server/util/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* Security utilities for OG image generation.
*
* Provides URL validation (SSRF protection), request coalescing (single-flight),
* and a concurrency semaphore. All utilities are edge-runtime compatible (no shared
* state across isolates, no Node.js-only APIs).
*/
import { useOgImageRuntimeConfig } from '../utils'

// ---------------------------------------------------------------------------
// SSRF protection: same-origin URL validation
// ---------------------------------------------------------------------------

// RFC 1918 / loopback / link-local patterns (covers IPv4 and common IPv6 forms)
const RE_PRIVATE_IP = /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.|169\.254\.|::1|fc00:|fd00:|fe80:|0\.0\.0\.0)/i

Check failure on line 15 in src/runtime/server/util/security.ts

View workflow job for this annotation

GitHub Actions / ci / lint

Unexpected useless alternative. This alternative is already covered by '0\.' and can be removed

Check failure on line 15 in src/runtime/server/util/security.ts

View workflow job for this annotation

GitHub Actions / ci / lint

Capturing group number 2 is defined but never used

Check failure on line 15 in src/runtime/server/util/security.ts

View workflow job for this annotation

GitHub Actions / ci / lint

Capturing group number 1 is defined but never used
const RE_LOCALHOST = /^localhost$/i

function extractHostname(url: string): string | null {
try {
return new URL(url).hostname
}
catch {
return null
}
}

/**
* Returns true when `hostname` is the same domain as `siteHostname` or a
* subdomain of it. e.g. siteHostname = "example.com" matches
* "cdn.example.com", "a.b.example.com", "example.com".
*/
function isSameOriginOrSubdomain(hostname: string, siteHostname: string): boolean {
const h = hostname.toLowerCase()
const s = siteHostname.toLowerCase()
return h === s || h.endsWith(`.${s}`)
}

/**
* Validate that a URL is safe to fetch from the server.
* Allows: same origin + subdomains of the site URL, plus any domains in the
* configurable `allowedDomains` list.
* Blocks: private/loopback IPs, non-http(s) protocols, localhost.
*/
export function isAllowedUrl(url: string, siteUrl?: string, allowedDomains?: string[]): boolean {
const hostname = extractHostname(url)
if (!hostname)
return false

// Block non-http(s) protocols
let protocol: string
try {
protocol = new URL(url).protocol
}
catch {
return false
}
if (protocol !== 'http:' && protocol !== 'https:')
return false

// Block private IPs and localhost
if (RE_PRIVATE_IP.test(hostname) || RE_LOCALHOST.test(hostname))
return false

// Allow same origin + subdomains
if (siteUrl) {
const siteHostname = extractHostname(siteUrl)
if (siteHostname && isSameOriginOrSubdomain(hostname, siteHostname))
return true
}

// Allow explicitly configured domains (with subdomain matching)
if (allowedDomains) {
for (const domain of allowedDomains) {
if (isSameOriginOrSubdomain(hostname, domain))
return true
}
}

return false
}

/**
* Convenience wrapper that reads site URL and allowedDomains from runtime config.
*/
export function validateExternalUrl(url: string, siteUrl: string): boolean {
const runtimeConfig = useOgImageRuntimeConfig()
return isAllowedUrl(url, siteUrl, runtimeConfig.allowedDomains)
}

// ---------------------------------------------------------------------------
// Request coalescing (single-flight)
// ---------------------------------------------------------------------------

const inFlight = new Map<string, Promise<any>>()

/**
* Ensures only one render executes per cache key at a time. Concurrent requests
* for the same key share the same Promise. Process-local, edge-compatible.
*/
export function coalesce<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = inFlight.get(key)
if (existing)
return existing as Promise<T>
const promise = fn().finally(() => inFlight.delete(key))
inFlight.set(key, promise)
return promise
}

// ---------------------------------------------------------------------------
// Concurrency semaphore
// ---------------------------------------------------------------------------

export type Semaphore = ReturnType<typeof createSemaphore>

/**
* Creates a simple in-process semaphore. Edge-compatible (no shared state).
* When the limit is reached, new callers wait in a FIFO queue.
*/
export function createSemaphore(limit: number) {
let active = 0
const queue: Array<() => void> = []

function release() {
active--
const next = queue.shift()
if (next)
next()
}

return {
async acquire(): Promise<void> {
if (active < limit) {
active++
return
}
return new Promise<void>((resolve) => {
queue.push(() => {
active++
resolve()
})
})
},
release,
/** Number of renders currently executing */
get active() { return active },
/** Number of requests waiting */
get waiting() { return queue.length },
}
}
16 changes: 16 additions & 0 deletions src/runtime/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ export function separateProps(options: OgImageOptions | undefined, ignoreKeys: s
return result as OgImageOptions
}

const DANGEROUS_ATTRS = new Set(['autofocus', 'contenteditable', 'tabindex', 'accesskey'])

/**
* Strip HTML event handlers and dangerous attributes from props to prevent
* reflected XSS via Vue fallthrough attributes (GHSA-mg36-wvcr-m75h).
*/
export function sanitizeProps(props: Record<string, any>): Record<string, any> {
const clean: Record<string, any> = {}
for (const key of Object.keys(props)) {
if (key.startsWith('on') || DANGEROUS_ATTRS.has(key.toLowerCase()))
continue
clean[key] = props[key]
}
return clean
}

export function withoutQuery(path: string) {
return path.split('?')[0]
}
Expand Down
23 changes: 23 additions & 0 deletions test/unit/security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { sanitizeProps } from '../../src/runtime/shared'

describe('sanitizeProps (GHSA-mg36-wvcr-m75h)', () => {
it('strips on* event handlers', () => {
const result = sanitizeProps({ title: 'Hello', onmouseover: 'alert(1)', onclick: 'steal()' })
expect(result).toEqual({ title: 'Hello' })
})

it('strips dangerous HTML attributes', () => {
const result = sanitizeProps({ title: 'Hi', autofocus: '', contenteditable: 'true', tabindex: '1', accesskey: 'x' })
expect(result).toEqual({ title: 'Hi' })
})

it('preserves legitimate props', () => {
const result = sanitizeProps({ title: 'Test', description: 'A page', colorMode: 'dark', theme: '#fff' })
expect(result).toEqual({ title: 'Test', description: 'A page', colorMode: 'dark', theme: '#fff' })
})

it('handles empty props', () => {
expect(sanitizeProps({})).toEqual({})
})
})
Loading