Summary
Nuxt OG Image has unauthenticated SSRF via `fonts[].path` URL parameter
Advisory details
Summary
nuxt-og-image exposes an unauthenticated HTTP endpoint at /_og/d/** that base64url-decodes and JSON.parses a fonts URL segment, then passes each fonts[i].path value directly into fetch() server-side without any URL validation (no scheme allowlist, no loopback/RFC1918 block, no host allowlist, no DNS rebinding mitigation).
Under the module's documented default configuration (security.strict = false, security.secret = "", restrictRuntimeImagesToOrigin = false), any caller able to reach the deployed Nuxt site can force the Nuxt server to issue arbitrary outbound GET requests to any host reachable from the server - including loopback, RFC1918 LAN, and cloud metadata services (AWS IMDS, GCE/Azure metadata, Kubernetes kubelet, internal admin panels, Redis/etcd/Consul/Vault HTTP APIs).
The chain is blind (Satori consumes the response as font bytes and silently discards non-fonts) but a robust side-channel exists: the outer HTTP status is 500 when the SSRF target returns 2xx, and 200 when it fails or returns non-2xx. This is sufficient to (a) enumerate live internal services and open ports, (b) confirm IMDSv1 reachability, and (c) detect credential issuance on environments still allowing IMDSv1.
Demonstrated end-to-end on a stock npm create nuxt@latest install with the module's documented default usage.
Detail
Endpoint registration (unauthenticated)
The module registers /_og/d/** and /_og/s/** with no authentication / Origin check / Sec‑Fetch‑Site validation:
// dist/shared/nuxt-og-image.DdbTs-xp.mjs : 5113-5133
addServerHandler({ route: "/_og/d/**", handler: resolve("./runtime/server/routes/image") })
addServerHandler({ route: "/_og/s/**", handler: resolve("./runtime/server/routes/image") })
Default security config (permissive)
// dist/shared/nuxt-og-image.DdbTs-xp.mjs : 5618-5640
security: {
strict: config.security?.strict ?? false, // <- gate disabled
secret: config.security?.secret ?? process.env.NUXT_OG_IMAGE_SECRET ?? "",
// ↑ no signature requirement
restrictRuntimeImagesToOrigin: config.security?.restrictRuntimeImagesToOrigin ?? false,
// ↑ inbound host allowlist disabled
maxQueryParamSize: config.security?.maxQueryParamSize ?? null,
renderTimeout: config.security?.renderTimeout ?? 15000,
imageFetchTimeout: config.security?.imageFetchTimeout ?? 3000,
}
The secret/signature branch is gated on secret && (truthy), so an empty string skips it entirely:
// dist/runtime/server/og-image/context.js : 49-69
const secret = runtimeConfig.security?.secret
let paramsSegment = encodedSegment
if (secret && !import.meta.dev && !import.meta.prerender) {
// signature enforcement happens HERE - but only if secret is non-empty.
// Default install: secret === "" -> entire block skipped.
}
Attacker-controlled deserialization of fonts
fonts is enumerated as a complex parameter: its value is base64url-decoded and then JSON.parsed straight into options:
// dist/runtime/shared/urlEncoding.js : 65
const COMPLEX_PARAMS = new Set(["satori","resvg","sharp","screenshot","takumi","fonts","_query","_path"])
// dist/runtime/shared/urlEncoding.js : 184-231
export function decodeOgImageParams(encoded) {
...
for (const part of parts) {
const idx = part.search(RE_SINGLE_UNDERSCORE)
if (idx === -1) continue
const alias = part.slice(0, idx)
let value = part.slice(idx + 1)
const paramName = PARAM_ALIASES[alias] || alias
if (COMPLEX_PARAMS.has(paramName)) {
try {
const json = b64Decode(value)
options[paramName] = JSON.parse(json) // <- attacker JSON survives unchanged
} catch { options[paramName] = value }
}
...
}
}
defu then merges attacker values into the request options:
// dist/runtime/server/og-image/context.js : 135
options = defu(queryParams, urlOptions, ogImageRouteRules, runtimeConfig.defaults)
// -> options.fonts = [{ name: "X", path: "<attacker-URL>", ... }]
From options.fonts to the unfettered fetch()
// dist/runtime/server/og-image/satori/renderer.js : 36-42
const fonts = await loadFontsForRenderer(event, {
...options,
fontDefs: options.fonts, // <- attacker array flows in
})
// dist/runtime/server/og-image/fonts.js : 175-201
export async function loadDefinedFonts(event, fontDefs) {
for (const def of fontDefs) {
if (!def || typeof def !== "object" || !def.path) continue // <- only validation
const fontConfig = { family: def.name, weight: def.weight||400, style: def.style, src: def.path, localPath: def.path }
const data = await resolve(event.e, fontConfig).catch(() => null)
...
}
}
The production binding (selected for every non-dev / non-prerender preset - dist/shared/nuxt-og-image.DdbTs-xp.mjs:5445-5452):
// dist/runtime/server/og-image/bindings/font-assets/node.js : 6-21 <- SINK
export async function resolve(event, font) {
const path = font.src || font.localPath // attacker-controlled
const { app } = useRuntimeConfig()
const fullPath = withBase(path, app.baseURL) // ufo.withBase returns absolute URLs unchanged
const origin = getNitroOrigin(event)
const timeout = getFetchTimeout(useOgImageRuntimeConfig()) // 3000 ms by default
const res = await fetch(
new URL(fullPath, origin).href, // <- when fullPath is absolute,
{ signal: AbortSignal.timeout(timeout) }, // origin is ignored
).catch(() => null) // -> fetch(attacker-URL)
...
}
ufo.withBase("http://target/", "/") returns "http://target/" unchanged when the input is already an absolute URL; new URL(abs, origin) then yields the absolute URL. No URL.protocol check, no IP-literal block, no DNS-resolution-aware allowlist, no redirect cap.
Side-channel for blind exfiltration
Although the response body is consumed as font bytes and Satori discards non-font payloads, the outer HTTP status code differs deterministically based on the SSRF target's response:
| Target returns | Satori behavior | Outer response |
|---|---|---|
2xx with non-font body |
parseFont(bytes) throws |
HTTP 500 |
Connection refused / timeout / non-2xx |
fetch().catch(() => null) -> fallback fonts used |
HTTP 200 (a PNG is returned) |
The boolean oracle (target alive & answered 2xx vs. not) is sufficient to:
- enumerate open ports on
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16 - detect cloud metadata reachability (and on legacy AWS IMDSv1, trigger credential issuance - even without read-back, the act of issuing credentials creates audit-trail and timing observables)
- distinguish health-check responses, vault-init status, k8s
kubelet/podsreachability, etc.
Steps To Reproduce
# 1. Create a stock Nuxt 4 app and add the module
npm create nuxt@latest lab-test --yes # accept defaults
cd lab-test
npm install nuxt-og-image # -> installs v6.6.0 (current latest)
nuxt.config.ts - the only change is enabling the module:
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
modules: ['nuxt-og-image'],
// NO `ogImage.security` overrides - accept module defaults.
})
The module requires at least one OG image component to be registered (its documented Hello‑World; otherwise the endpoint returns 500 No OG Image components found). Add the minimal one:
mkdir -p app/components/OgImage
cat > app/components/OgImage/Default.satori.vue <<'EOF'
<script
References
- https://github.com/advisories/GHSA-q8hw-4fvp-9rwv
- https://github.com/nuxt-modules/og-image/security/advisories/GHSA-q8hw-4fvp-9rwv
- https://github.com/nuxt-modules/og-image/pull/637
- https://github.com/nuxt-modules/og-image/commit/243cac2228671d3711c2bd65e300c278fcdf5a4e
- https://github.com/nuxt-modules/og-image/releases/tag/v6.7.0
Related vulnerabilities
All Supply chain →- MEDIUMCVE-2026-96747
PyMongo: PYTHON-5990 Forced Unix domain socket connection via a .sock KMS endpoint in client-side field level encryption
- MEDIUMCVE-2026-56678
9router: Kiro region injection allows authenticated SSRF with Authorization header forwarding
- HIGHGHSA-jgh3-fggc-mcpm
Obot: Server-Side Request Forgery via remote MCP server URL
- HIGHCVE-2026-78682
NLTK: pathsec SSRF protection can be bypassed when a proxy is configured
- HIGHCVE-2026-75975
fast-uri vulnerable to server-side request forgery via malformed IPv6 normalization
- MEDIUMCVE-2026-73845
CKAN MCP Server: MQA server allowlist bypass via unanchored regex (`isValidMqaServer`)