{"id":"GHSA-c2rm-g55x-8hr5","summary":"nuxt-og-image SSRF — bypass of GHSA-pqhr-mp3f-hrpp / v6.2.5 fix (IPv6 + redirect)","details":"## Summary\n\nThe `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`:\n\n1. **IPv6 prefix list is incomplete.** The IPv6 branch checks only `bare === \"::1\" || startsWith(\"fc\") || startsWith(\"fd\") || startsWith(\"fe80\")`. It misses:\n   - `[::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.**\n   - `[fec0::/10]` (RFC 3879 site-local — deprecated but still routable on legacy networks)\n   - `[5f00::/16]` (RFC 9602 SRv6 SIDs)\n   - `[3fff::/20]` (RFC 9637 IPv6 documentation v2)\n   - `[64:ff9b:1::/48]` (RFC 8215 NAT64 local-use, including embedded IPv4 loopback `[64:ff9b:1::7f00:1]`)\n\n2. **No redirect re-validation.** `isBlockedUrl` runs once on the initial `\u003cimg src\u003e`. 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.\n\nThe 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.\n\n## Affected\n\n| Package          | Version           | Role                                                |\n|------------------|-------------------|-----------------------------------------------------|\n| `nuxt-og-image`  | `6.4.8` (latest)  | default OG-image generator for Nuxt apps            |\n| `@nuxtjs/og-image` (alias) | same          | re-export, same code path                            |\n\nThe vulnerable code lives in `dist/runtime/server/og-image/core/plugins/imageSrc.js` and is enforced for every `\u003cimg src\u003e` (and `style=\"background-image: url(...)\"`) inside an OG image component, on production builds (`!import.meta.dev`).\n\n## Vulnerable code (`imageSrc.js`, verbatim)\n\n```js\nfunction isPrivateIPv4(a, b) {\n  if (a === 127) return true;\n  if (a === 10) return true;\n  if (a === 172 && b \u003e= 16 && b \u003c= 31) return true;\n  if (a === 192 && b === 168) return true;\n  if (a === 169 && b === 254) return true;\n  if (a === 0) return true;\n  return false;\n}\nfunction isBlockedUrl(url) {\n  let parsed;\n  try { parsed = new URL(url); } catch { return true; }\n  if (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return true;\n  const hostname = parsed.hostname.toLowerCase();\n  const bare = hostname.replace(RE_IPV6_BRACKETS, \"\");\n  if (bare === \"localhost\" || bare.endsWith(\".localhost\")) return true;\n  const mappedV4 = bare.match(RE_MAPPED_V4);   // /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/\n  const ip = mappedV4 ? mappedV4[1] : bare;\n  const parts = ip.split(\".\");\n  if (parts.length === 4 && parts.every((p) =\u003e RE_DIGIT_ONLY.test(p))) {\n    /* dotted-decimal IPv4 path */\n  }\n  if (RE_INT_IP.test(ip)) {\n    /* single-integer IPv4 path */\n  }\n  if (bare === \"::1\" || bare.startsWith(\"fc\") || bare.startsWith(\"fd\") || bare.startsWith(\"fe80\"))\n    return true;                                  // ← gap: only 4 IPv6 prefixes\n  return false;                                   // ← everything else is \"public\"\n}\n\n// Then:\nasync function doResolveSrcToBuffer(src, kind, ctx) {\n  ...\n  if (!import.meta.dev && isBlockedUrl(decodedSrc)) {\n    return { blocked: true };\n  }\n  const buffer = await $fetch(decodedSrc, {     // ← follows 30x by default\n    responseType: \"arrayBuffer\",\n    timeout: fetchTimeout,\n  });\n  ...\n}\n```\n\nTwo distinct issues:\n\n- **The IPv6 prefix list is hand-rolled** (`fc`, `fd`, `fe80`, `::1`) and inherits no taxonomy from `ipaddr.js` or any RFC table.\n- **`$fetch` is `ofetch`**, which wraps Node `fetch()` with default `redirect: \"follow\"`. The validator does not run on the redirect target.\n\n## Reproducer (verbatim, no host privilege)\n\nEnd-to-end test of `isBlockedUrl` on a corpus of internal-IP forms, paired with empirical `fetch()` confirming which forms actually reach loopback. Verbatim output:\n\n```\n  isBlockedUrl?  fetch reaches loopback?  url\n  -------------  -----------------------  ---\n  ✓ blocked      YES                      http://127.0.0.1:8765/             (control: dotted-decimal loopback)\n  ✓ blocked      YES                      http://localhost:8765/             (control)\n  ✓ blocked      no(ECONNREFUSED)         http://[::1]:8765/                 (control: IPv6 loopback)\n  ✓ blocked      no(EHOSTUNREACH)         http://169.254.169.254:8765/       (control: AWS IMDS)\n  ✓ blocked      YES                      http://2130706433:8765/            (control: decimal-int IPv4)\n  ✓ blocked      YES                      http://0x7f000001:8765/            (control: hex-int IPv4)\n  ✓ blocked      YES                      http://0177.0.0.1:8765/            (control: octal — URL parser canonicalizes)\n  ✓ blocked      YES                      http://127.1:8765/                 (control: shorthand — URL parser canonicalizes)\n\n  ✗ NOT blocked  YES                      http://[::ffff:7f00:1]:8765/       (BYPASS: IPv6-mapped, hex form)\n  ✗ NOT blocked  no(unreachable)          http://[fec0::1]:8765/             (BYPASS: RFC 3879 site-local)\n  ✗ NOT blocked  no(unreachable)          http://[5f00::1]:8765/             (BYPASS: RFC 9602 SRv6)\n  ✗ NOT blocked  no(unreachable)          http://[3fff::1]:8765/             (BYPASS: RFC 9637 docs)\n  ✗ NOT blocked  no(unreachable)          http://[64:ff9b:1::1]:8765/        (BYPASS: RFC 8215 NAT64)\n  ✗ NOT blocked  no(unreachable)          http://[64:ff9b:1::7f00:1]:8765/   (BYPASS: NAT64 + embedded loopback)\n```\n\nThe 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.\n\nThe \"control\" rows confirm the bypass set is minimal — the validator catches the obvious cases. The bypasses are the cases the prefix list forgot.\n\n### Class 2: redirect amplifier\n\n`$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 `\u003cno response\u003e 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.\n\nSame primitive as #29 / #38 (ipx redirect bypass), in a different validator. The fix recommendations for #29 also apply here, with the same trade-offs.\n\n## Impact\n\nA 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:\n\n- **Class 1 directly:** `\u003cimg src=\"http://[::ffff:7f00:1]:PORT/path\"\u003e` 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.\n- **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.\n- **Class 2 redirect:** any allowed CDN with a redirect rule extends the reach to all RFC 1918 / loopback / link-local space.\n\n`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 `\u003cNuxtLink to=\"/og?avatar=...\"\u003e` pattern Nuxt docs encourage.\n\n## Suggested fix\n\nThree non-exclusive options:\n\n1. **Replace the hand-rolled IPv6 prefix list with `ipaddr.js`'s `range()` predicate** (or equivalent), then either:\n   - explicitly deny the four cluster ranges that `ipaddr.js` currently misses (`fec0::/10`, `5f00::/16`, `3fff::/20`, `64:ff9b:1::/48`), or\n   - wait for the `ipaddr.js` upstream patch (see Vercel #27 — same gap, separately disclosed) and bump.\n   - In any case, also catch `[::ffff:7f00:1]` either by widening `RE_MAPPED_V4` or by classifying any `::ffff:` address as the embedded IPv4.\n\n2. **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.)\n\n3. **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.\n\n(2) alone closes Class 2. (1) alone closes Class 1. (3) closes both with one change.","aliases":["CVE-2026-44589"],"modified":"2026-05-16T00:06:28.251916Z","published":"2026-05-07T20:52:30Z","database_specific":{"github_reviewed_at":"2026-05-07T20:52:30Z","cwe_ids":["CWE-918"],"nvd_published_at":"2026-05-14T19:16:38Z","severity":"LOW","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/nuxt-modules/og-image/security/advisories/GHSA-c2rm-g55x-8hr5"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-44589"},{"type":"PACKAGE","url":"https://github.com/nuxt-modules/og-image"}],"affected":[{"package":{"name":"nuxt-og-image","ecosystem":"npm","purl":"pkg:npm/nuxt-og-image"},"ranges":[{"type":"SEMVER","events":[{"introduced":"6.2.5"},{"fixed":"6.4.9"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-c2rm-g55x-8hr5/GHSA-c2rm-g55x-8hr5.json"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N"}]}