GHSA-c2rm-g55x-8hr5

Suggest an improvement
Source
https://github.com/advisories/GHSA-c2rm-g55x-8hr5
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-c2rm-g55x-8hr5/GHSA-c2rm-g55x-8hr5.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-c2rm-g55x-8hr5
Aliases
  • CVE-2026-44589
Published
2026-05-07T20:52:30Z
Modified
2026-05-07T21:04:38.213789Z
Severity
  • 3.7 (Low) CVSS_V3 - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N CVSS Calculator
Summary
nuxt-og-image SSRF — bypass of GHSA-pqhr-mp3f-hrpp / v6.2.5 fix (IPv6 + redirect)
Details

Summary

The 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:

  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 (REMAPPEDV4 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)

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 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 fix

Three non-exclusive options:

  1. 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), or
    • wait 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.
  2. 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.)

  3. 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.

Database specific
{
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-07T20:52:30Z",
    "cwe_ids": [
        "CWE-918"
    ],
    "severity": "LOW",
    "nvd_published_at": null
}
References

Affected packages

npm / nuxt-og-image

Package

Affected 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"