GHSA-c2rm-g55x-8hr5: nuxt-og-image SSRF — bypass of GHSA-pqhr-mp3f-hrpp / v6.2.5 fix (IPv6 + redirect)
SummaryThe `isBlockedUrl()` denylist introduced in `nuxt-og-image@6.2.5` 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`:**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]`)**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 gaps** — `nuxt-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)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: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.ImpactA 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 function on the same machine in self-hosted setups), it leaks.**Class 1 cluster:** the IPv6 cluster ranges trigger only on dual-stack / k8s / NAT64 networks — but those are exactly the production targets where SSRF matters most.**Class 2 redirect:** any allowed CDN with a redirect rule extends the reach to all RFC 1918 / loopback / link-local space.`nuxt-og-image` is the OG-image module recommended in Nuxt's official documentation; it is shipped with Nuxt UI templates and is one of the top-2 Nuxt modules by GitHub stars. The user-facing primitive in real apps is "title/avatar comes from a request param" — exactly the same `<NuxtLink to="/og?avatar=...">` pattern Nuxt docs encourage.Suggested fixThree non-exclusive options:**Replace the hand-rolled IPv6 prefix list with `ipaddr.js`'s `range()` predicate** (or equivalent), then either:explicitly deny the four cluster ranges that `ipaddr.js` currently misses (`fec0::/10`, `5f00::/16`, `3fff::/20`, `64:ff9b:1::/48`), orwait for the `ipaddr.js` upstream patch (see Vercel #27 — same gap, separately disclosed) and bump.In any case, also catch `[::ffff:7f00:1]` either by widening `RE_MAPPED_V4` or by classifying any `::ffff:` address as the embedded IPv4.**Pass `redirect: "manual"` in `$fetch` defaults** and reject 3xx. (Compare `astro:assets`, which already does this — `await fetch(url, { redirect: "manual" })` and explicit 3xx-rejection.)**Pin the validated IP to the connection.** Resolve the hostname once during validation, then pass a custom `undici.Agent` with `connect.lookup` returning the resolved IP only. This closes both the IPv6 bypass class (the resolved IP is checked again) and the redirect class (post-30x lookup is forced to the original IP). Reference: `request-filtering-agent` on npm.(2) alone closes Class 2. (1) alone closes Class 1. (3) closes both with one change.
CVSS v3.1: 3.7