Next.js 16.3.6 (22 September 2026) fixes CVE-2026-94545 (GHSA-vcvr-r3jv-pc5j), a critical flaw in next/og's ImageResponse: the library that turns JSX into social-card images serialized XML with no escaping at all. Any application passing attacker-controlled input into SVG rendered by the Node.js ImageResponse is affected. This post walks through the root cause from the actual fix commit, an end-to-end reproduction with pixel-level evidence, and a detection lesson that cost us a nuclei template to learn.
What it is
| Field | Value |
|---|---|
| CVE | CVE-2026-94545 (Vercel advisory) |
| CVSS | 9.5 Critical (Vercel assessment, CVSS v4) |
| Class | XML injection: SVG content, attribute names, attribute values and styles were serialized unescaped, leading to RCE |
| Component | Satori, the JSX-to-SVG engine behind next/og, vendored into the next package |
| Authentication | None. One unauthenticated query parameter is enough in the canonical pattern |
| Affected | Next.js 16.2.0 through 16.3.5, Node.js ImageResponse only |
| Fixed in | Next.js 16.3.6 and satori 0.33.5 (both 22 September 2026) |
| Not affected | The Edge ImageResponse implementation; apps that never pass untrusted input into SVG |
Background
ImageResponse from next/og is the standard way to generate social-card images in Next.js: you write JSX, Satori converts it to an SVG document, and a rasterizer (resvg) turns that into a PNG. The typical vulnerable pattern, straight from the advisory:
import { ImageResponse } from 'next/og'
export async function GET(request: Request) {
const value = new URL(request.url).searchParams.get('value') ?? ''
return new ImageResponse(
<svg width="1200" height="630">
<title>{value}</title>
</svg>
)
}
If value is attacker-controlled, what exactly happens to it on the way into the final SVG? That was the question worth answering precisely.
Root cause: six escaping gaps in one serializer
The upstream advisory (GHSA-wx4j-mvgx-mqwp) is short on mechanics, so we went to the fix: satori commit 26a52aff, "Harden SVG serialization," merged 22 Sep. When you pass an <svg> element into the pipeline, translateSVGNodeToSVGString() in src/handler/preprocess.ts serializes it. The vulnerable code, with the fix alongside:
1. Text nodes were not escaped.
// vulnerable
if (typeof node !== 'object') return String(node)
// fixed
if (typeof node !== 'object') return escapeXMLText(node)
A text child of <title> went into the output verbatim. </title><rect .../> stops being text and becomes markup.
2. Attribute values were interpolated raw inside quotes.
// vulnerable
return ` ${ATTRIBUTE_MAPPING[k] || k}="${_v}"`
// fixed: routed through buildXMLString() with escapeXMLAttribute()
A value containing " closes the attribute and opens arbitrary new ones.
3. Attribute names were interpolated raw. Object keys went straight into the tag; a crafted prop name injects attributes. The fix validates every name against the XML Name production and throws on anything else.
4. Style values were joined into style="k:v;k2:v2" without escaping.
5. The embedded-SVG data-URL encoder ignored &. Nested <svg> nodes become data:image/svg+xml URLs. The character class that percent-encodes the inner document omitted &, so entities like < in the inner SVG survived the outer SVG parse as live markup. The fix adds & to the encoded set.
6. Internal style properties were trusted. expand.ts passed any style key starting with _ straight into the serialized style block ("internal properties"). The fix throws on them: internal fields are no longer smugglable through attacker-controlled style objects.
Every one of these paths now flows through a single hardening function, buildXMLString(), which escapes values and validates XML names. When a security fix centralizes serialization like this, it usually means the maintainers concluded the bug class was broader than the reported instance.
Reproduction: proving it at three layers
Layer 1, the serializer (npm satori 0.25.0 vs 0.33.5):
const el = h('svg', { viewBox: '0 0 1200 630', width: 1200, height: 630 },
h('title', null, '</title><rect x="50" y="250" width="500" height="150" fill="red"/><title>'))
Satori 0.25.0's output contains the rect as a live element; 0.33.5 (the fixed release, published the same day as the Next.js patch) contains </title> as inert text. The payload is deliberately balanced XML: an unbalanced one produces a stray closing tag, the inner document fails XML parsing, and nothing renders.
Layer 2, the vendored Next.js bundle. next/og vendors @vercel/og (satori included) inside the next package. Rendering the same element through the compiled bundle from [email protected] vs [email protected]: the vulnerable bundle rasterizes the injected rect into the PNG; the patched one does not.
Layer 3, over HTTP. A loopback app serving the advisory's route pattern, and a scanner that sends one GET and analyzes the response. Evidence: the injected rect is 500x150 pure red, so a vulnerable response contains exactly 75,000 #FF0000 pixels. The scanner counts them with a minimal stdlib PNG decoder. Result: 75,000 pixels on 16.3.5, zero on 16.3.6.
The detection detail that matters: why a rectangle
The first marker was injected text. It rendered nothing, and the reason is worth knowing: text inside the embedded SVG has no loadable font in the rasterizer's environment. Satori supplies fonts for its own layout text (converted to paths), but arbitrary markup inside an embedded SVG document gets no font, so injected <text> is invisible even when the injection works. The marker must be a font-free shape. If you are building detection for this class of bug, that distinction will save you an afternoon.
The nuclei lesson
The obvious nuclei heuristic is a benign request versus a payload request, matched on PNG size difference. It matched the vulnerable lab. It also matched the patched lab, because on a patched system the escaped payload is not inert; it renders as literal (harmless) visible text, and that changes the response size too. The size deltas (6283 vs 4822 bytes) are separated by nothing but app-specific compression noise.
So the template was deleted, and the lab README says why. The conclusion generalizes: when the proof of a vulnerability lives in the content of a rendered artifact, byte-level response heuristics will lie to you in both directions. Evaluate the artifact or do not claim the detection.
Impact, stated precisely
Proven here: full control of the embedded SVG document in the generated image, from one unauthenticated query parameter, against the exact pattern Vercel's advisory shows. The Next.js advisory goes further ("could lead to remote code execution") but attributes that to the downstream SVG parser, whose vulnerable details are not public as of writing. We did not verify RCE and this post does not claim it. Also untested: SSRF or file reads via injected resource references; both are plausible directions for follow-up work and both are gated by the same fix.
What to do
- Upgrade to Next.js 16.3.6 (16.2.0 through 16.3.5 are affected). The 15.x line is not affected by the RCE issue; 15.5.26 carries related hardening anyway.
- The workaround is the real design rule: never pass untrusted input into SVG content, attributes, or styles rendered by
ImageResponse. Treat og-image parameters as markup, not text. - For detection on your own systems: a behavioral probe (benign vs marker payload, compare rendered output) is reliable; version banners and response-size heuristics are not.
- The Edge
ImageResponseimplementation is not affected.
The full lab, scanner, and root-cause notes are on GitHub. Vulnerability reported through Vercel's program; this analysis is independent third-party work, published after the fix shipped.
Frequently Asked Questions
Which Next.js versions are affected by CVE-2026-94545?
16.2.0 through 16.3.5, and only the Node.js ImageResponse implementation from next/og. The Edge implementation is not affected, and the 15.x line is not affected by the RCE issue. The fix is Next.js 16.3.6 and satori 0.33.5, both released 22 September 2026.
My og-image route doesn't use SVG. Am I affected?
Only if attacker-controlled values reach SVG content, attributes, or styles inside what you pass to ImageResponse on the Node.js runtime. A plain div-based card with sanitized, length-capped text inputs does not match the vulnerable pattern — but upgrade anyway, because the fix hardens every serialization path, not just the reported one.
Can I detect exploitation in my logs?
Not reliably by response size or status code: a patched server renders the escaped payload as harmless visible text, which changes the response size too. A behavioral probe works — send a marker payload that renders a distinctive font-free shape and inspect the returned image content. Byte-level heuristics lie in both directions.
This bug shipped in the image-generation path of one of the most deployed web frameworks on earth, and the fix is a reminder that anything a request parameter touches is markup until proven otherwise. It is exactly the assumption we test against in a penetration test, the habit we build into secure web development, and the kind of upstream dependency churn we track for clients through DevSecOps pipeline integration. If you are not sure whether your og-image routes survived the upgrade, talk to us and we will check with you.



