Server-side request forgery (SSRF) In nuxt-og-image

Description

nuxt-og-image SSRF — bypass of GHSA-pqhr-mp3f-hrpp / v6.2.5 fix (IPv6 + redirect) ## Summary The isBlockedUrl() denylist introduced in [email protected] to remediate GHSA-pqhr-mp3f-hrpp (Dmitry Prokhorov / Positive Technologies, March 2026) is incomplete. The patch advisory states "Decimal/hexadecimal IP encoding bypasses are also handled" — that part is true (Node's WHATWG URL parser canonicalizes those forms before validation), but the v6.2.5 implementation misses two independent surfaces in the latest release 6.4.8: 1. IPv6 prefix list is incomplete. The IPv6 branch checks only bare === "::1" || startsWith("fc") || startsWith("fd") || startsWith("fe80"). It misses: - [::ffff:7f00:1] — IPv6-mapped IPv4 loopback in pure-hex form (RE_MAPPED_V4 regex requires dotted-quad). Reaches 127.0.0.1 on a single-stack-IPv4 host with no other primitive needed. - [fec0::/10] (RFC 3879 site-local — deprecated but still routable on legacy networks) - [5f00::/16] (RFC 9602 SRv6 SIDs) - [3fff::/20] (RFC 9637 IPv6 documentation v2) - [64:ff9b:1::/48] (RFC 8215 NAT64 local-use, including embedded IPv4 loopback [64:ff9b:1::7f00:1]) 2. No redirect re-validation. isBlockedUrl runs once on the initial <img src>. The subsequent $fetch(decodedSrc, ...) (ofetch, default redirect-follow) follows 30x responses with no second-pass validation. Any allowed origin that returns a 302 to an internal IP — S3 redirect rules, GCS, Azure, CloudFront, any user-content CDN where the attacker can place a single redirect — completes the SSRF. The net result is that the v6.2.5 SSRF advisory is bypassable in two distinct ways. The same root family as #29 / #38 (ipx) but in a different code path with different gapsnuxt-og-image does not delegate to ipx, it ships its own validator, and that validator has fresh issues that survived the prior fix. ## Affected | Package | Version | Role | |------------------|-------------------|-----------------------------------------------------| | nuxt-og-image | 6.4.8 (latest) | default OG-image generator for Nuxt apps | | @nuxtjs/og-image (alias) | same | re-export, same code path | The vulnerable code lives in dist/runtime/server/og-image/core/plugins/imageSrc.js and is enforced for every <img src> (and style="background-image: url(...)") inside an OG image component, on production builds (!import.meta.dev). ## Vulnerable code (imageSrc.js, verbatim) js function isPrivateIPv4(a, b) { if (a === 127) return true; if (a === 10) return true; if (a === 172 && b >= 16 && b <= 31) return true; if (a === 192 && b === 168) return true; if (a === 169 && b === 254) return true; if (a === 0) return true; return false; } function isBlockedUrl(url) { let parsed; try { parsed = new URL(url); } catch { return true; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return true; const hostname = parsed.hostname.toLowerCase(); const bare = hostname.replace(RE_IPV6_BRACKETS, ""); if (bare === "localhost" || bare.endsWith(".localhost")) return true; const mappedV4 = bare.match(RE_MAPPED_V4); // /^::ffff:(\d+\.\d+\.\d+\.\d+)$/ const ip = mappedV4 ? mappedV4[1] : bare; const parts = ip.split("."); if (parts.length === 4 && parts.every((p) => RE_DIGIT_ONLY.test(p))) { /* dotted-decimal IPv4 path */ } if (RE_INT_IP.test(ip)) { /* single-integer IPv4 path */ } if (bare === "::1" || bare.startsWith("fc") || bare.startsWith("fd") || bare.startsWith("fe80")) return true; // ← gap: only 4 IPv6 prefixes return false; // ← everything else is "public" } // Then: async function doResolveSrcToBuffer(src, kind, ctx) { ... if (!import.meta.dev && isBlockedUrl(decodedSrc)) { return { blocked: true }; } const buffer = await $fetch(decodedSrc, { // ← follows 30x by default responseType: "arrayBuffer", timeout: fetchTimeout, }); ... } Two distinct issues: - The IPv6 prefix list is hand-rolled (fc, fd, fe80, ::1) and inherits no taxonomy from ipaddr.js or any RFC table. - $fetch is ofetch, which wraps Node fetch() with default redirect: "follow". The validator does not run on the redirect target. ## Reproducer (verbatim, no host privilege) End-to-end test of isBlockedUrl on a corpus of internal-IP forms, paired with empirical fetch() confirming which forms actually reach loopback. Verbatim output: isBlockedUrl? fetch reaches loopback? url ------------- ----------------------- --- ✓ blocked YES http://127.0.0.1:8765/ (control: dotted-decimal loopback) ✓ blocked YES http://localhost:8765/ (control) ✓ blocked no(ECONNREFUSED) http://[::1]:8765/ (control: IPv6 loopback) ✓ blocked no(EHOSTUNREACH) http://169.254.169.254:8765/ (control: AWS IMDS) ✓ blocked YES http://2130706433:8765/ (control: decimal-int IPv4) ✓ blocked YES http://0x7f000001:8765/ (control: hex-int IPv4) ✓ blocked YES http://0177.0.0.1:8765/ (control: octal — URL parser canonicalizes) ✓ blocked YES http://127.1:8765/ (control: shorthand — URL parser canonicalizes) ✗ NOT blocked YES http://[::ffff:7f00:1]:8765/ (BYPASS: IPv6-mapped, hex form) ✗ NOT blocked no(unreachable) http://[fec0::1]:8765/ (BYPASS: RFC 3879 site-local) ✗ NOT blocked no(unreachable) http://[5f00::1]:8765/ (BYPASS: RFC 9602 SRv6) ✗ NOT blocked no(unreachable) http://[3fff::1]:8765/ (BYPASS: RFC 9637 docs) ✗ NOT blocked no(unreachable) http://[64:ff9b:1::1]:8765/ (BYPASS: RFC 8215 NAT64) ✗ NOT blocked no(unreachable) http://[64:ff9b:1::7f00:1]:8765/ (BYPASS: NAT64 + embedded loopback) The first six bypass rows say "✗ NOT blocked" — that is isBlockedUrl returning false (i.e., "this URL is fine to fetch") for each of those addresses. The "fetch reaches loopback" column shows that [::ffff:7f00:1] actually round-trips to 127.0.0.1 on a single-stack-IPv4 dev box; the four cluster ranges are unreachable on the dev box but succeed on dual-stack / k8s / NAT64 / SRv6 networks where any of these prefixes is internally bound. The "control" rows confirm the bypass set is minimal — the validator catches the obvious cases. The bypasses are the cases the prefix list forgot. ### Class 2: redirect amplifier $fetch(url, { responseType: "arrayBuffer", timeout }) follows 30x by default. Confirmed empirically — ofetch('http://lab.menna.website/test/redirect-to-loopback') (where lab.menna.website returns 302 Location: http://127.0.0.1/) ends with <no response> fetch failed after the connect attempt to 127.0.0.1:80, proving the redirect was followed. On a target where the redirect destination has a service bound, the bytes round-trip back through the OG renderer. Same primitive as #29 / #38 (ipx redirect bypass), in a different validator. The fix recommendations for #29 also apply here, with the same trade-offs. ## Impact A Nuxt application that uses nuxt-og-image (the official-recommended OG generator) and includes any user-influenced URL in an OG component is vulnerable to SSRF that returns the bytes of the internal response as part of the rendered OG image: - Class 1 directly: <img src="http://[::ffff:7f00:1]:PORT/path"> reaches 127.0.0.1 on the OG worker. If the dev's deployment has anything bound to loopback (admin dashboards, internal HTTP-RPC, Redis HTTP UI, anything running alongside the

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Package
Affected version
Patched versions