This website uses cookies

Read our Privacy policy and Terms of use for more information.

On September 22, a critical advisory landed for next/og. Seven days later, on September 30, a second release patched seven more issues, and most of them had something in common that nobody put in a headline: they sat in code that turns a request into an image, or that caches the result of one.

If you run a Next.js app, you probably already upgraded. Good. This post is not about the patch. It is about why a feature most teams treat as decoration, the dynamic social card, keeps showing up in security advisories, and what that tells you about the other "harmless" routes in your codebase.

My thesis: an image route that takes input is not a view. It is a small interpreter. It parses a mini-language, hands the result to a native library, and caches the output under a key you derive from a URL. Every one of those three steps has a classic failure mode, and September's releases hit all three.

What actually shipped

The first release, announced September 22, fixed a flaw in ImageResponse from next/og. According to the GitHub advisory as mirrored by GitLab, the affected range is Next.js 16.2.0 through 16.3.5, the fix is 16.3.6, and the bug is rated critical. The root cause was upstream. Next.js converts your JSX-like layout to SVG using Satori, and Satori did not properly escape attacker-controlled values before writing them into the SVG. The SVG then went to a downstream renderer, and a crafted value could be interpreted as markup instead of text. On the Node.js runtime, that chain ends in remote code execution. The Edge implementation was not affected, and neither was Next.js 15, although 15.5.26 added hardening.

The precondition matters, and it is easy to skim past. The app has to pass attacker-controlled values into SVG content, attributes, or styles. A card that renders a static title is fine. A card that renders ?title= from the query string is the textbook case.

The second release, on September 30, was bigger in count and quieter in tone. The Next.js team's security release post lists issues across the image optimizer, the response cache, the metadata image routes, and the new use cache machinery. Fixed versions are 16.3.8 and 15.5.27. The team had announced nine vulnerabilities in advance, then shipped seven, because the remaining two, one critical and one high, were waiting on upstream coordination. That detail deserves attention. The framework team could not ship its own fix, because the vulnerable code was not theirs.

Failure mode one: the input is a program, not a string

Look at how a dynamic OG image is usually written.

// app/og/route.tsx
import { ImageResponse } from 'next/og'
export async function GET(req: Request) {
  const { searchParams } = new URL(req.url)
  const title = searchParams.get('title') ?? 'Software Letters'
  return new ImageResponse(
    (
      <div style={{ fontSize: 64, display: 'flex' }}>
        {title}
      </div>
    ),
    { width: 1200, height: 630 }
  )
}

This looks like string interpolation into a template, the same thing you do in every React component. React protects you in the DOM because it escapes text nodes. But here the destination is not the DOM. It is an SVG document that a second library will parse. The escaping contract belongs to a different layer, and when that layer gets it wrong, your perfectly idiomatic component becomes an injection point.

This is the same shape as SQL injection, just with a friendlier face. The query builder looks like code you wrote, the value looks like a string, and somewhere below your line of sight the two get concatenated. Nobody would write SELECT * FROM users WHERE name = '${name}' in 2026. But <svg>${title}</svg> hides inside a JSX expression, and the muscle memory that fires on SQL does not fire here.

Netlify's guidance is the right interim posture: do not place untrusted input inside elements passed to ImageResponse, either escape it as XML first or leave it out of the image entirely. I would go further and treat it as a rule for any renderer that is not the browser DOM. PDF generators, email template engines, and chart libraries that emit SVG all have the same property. If the output format has its own grammar, your string needs its own escaping.

Failure mode two: the allow-list is the attack surface

The high-severity bug in the September 30 release was a server-side request forgery in Image Optimization. The advisory (CVE-2026-94483) says an attacker-controlled, allow-listed remote URL can lead to SSRF, for example toward private IP ranges. If you have no images.remotePatterns configured, you are not affected.

Read that again, because the interesting part is the word "allow-listed." The mitigation most teams adopted, restricting which remote hosts the optimizer will fetch, is evidently not enough on its own. An allow-list answers the question "which hostnames may I fetch?" It does not answer "what does that hostname resolve to right now?" or "where does that URL redirect?" Classic SSRF defenses care about the resolved address, not the string you matched against. A pattern like **.example-cdn.com is only as safe as every subdomain under it and every DNS record behind it.

Here is how it goes wrong in practice. Someone adds hostname: '**.amazonaws.com' because the product images live in an S3 bucket, and the wildcard quietly admits every bucket on the platform, including ones an attacker controls. The optimizer is a fetcher with your server's network position. Treat its configuration like a firewall rule, not like a CORS header.

A practical check takes five minutes. Open next.config.js, list every entry under images.remotePatterns, and for each one ask whether an outsider can put content, or a redirect, at a URL matching it. If the answer is yes for any entry, narrow it to the exact hostname and path prefix, and consider whether the optimizer needs to run at all for that source.

Failure mode three: the cache key is a claim

Three of the seven issues in the September 30 release are variations on one theme: the cache served the wrong thing to the wrong person. A self-hosted Pages Router app could have a page's cache entry replaced with content from a different route (CVE-2026-94543). A root-level catch-all combined with static or ISR routes could have its shared response cache poisoned by a single unauthenticated request (CVE-2026-94484). And pending use cache fills were shared across requests without distinguishing Draft Mode from regular traffic (CVE-2026-94544), so a visitor who overlapped an editor's preview request could receive unpublished content, and in the worst case that content could be baked into a prerendered page for everyone.

Caches are where every framework's security model quietly lives. A cache key is a claim that says "these two requests are equivalent." When the claim is wrong, you do not get a crash. You get one user's data on another user's screen, or a page that confidently serves the wrong route until someone revalidates it. Nothing in your logs looks broken.

The use cache bug is the instructive one. Draft Mode is a per-request authorization state: this request is allowed to see unpublished content. The cache treated two requests with the same function arguments as interchangeable and ignored that the viewer's privileges differed. If you have ever written cache(fn, key) by hand, you know the rule: any variable that changes the answer belongs in the key, and "who is asking, and what are they allowed to see" is a variable. Frameworks can encode that rule for you, but only for the variables they know about.

One more entry from that list rhymes with all of this. The metadata image routes, opengraph-image and twitter-image, ignored the dynamicParams option in webpack builds (CVE-2026-94485), so an attacker could request image URLs for segments you had deliberately excluded from generateStaticParams(). Turbopack builds were not affected. The social card was again the forgotten door in a house whose front door was locked.

The yes, but

The fair objection is that this is a story about one framework having a bad fortnight, and that every large project ships advisories. That is true. Seven issues in a release, one high and the rest medium or low, is not alarming by itself, and the Next.js team's turnaround, a pre-announcement a week ahead and a transparent note about the two delayed fixes, is closer to a model than a cautionary tale.

But I would resist the "bad fortnight" reading for a specific reason. The critical bug was not in Next.js code at all. It was upstream, in a layout-to-SVG library, and it was only reachable because applications pass request data into it. That is the ordinary shape of modern supply chain risk: the exploit lives in a dependency three layers down, and the trigger lives in your application's habit of treating a query parameter as safe text. Upgrading fixes the first half. Only you can fix the second.

There is also a fair point that most ImageResponse usage is static, a logo and a post title pulled from your own CMS. That lowers your exposure, and you should say so in your risk assessment. But "title from my CMS" is one editor account compromise or one imported feed away from "title from an attacker," and the cost of escaping is a single function call.

What to do on Monday

Upgrade first: 16.3.8 or 15.5.27, which carry the earlier next/og fix as well. Then spend an hour on the audit the patch notes imply.

Grep for every ImageResponse and list the values that reach it. Anything derived from searchParams, headers, or user-generated content should be either removed or XML-escaped before it enters the layout. Review images.remotePatterns and replace wildcards with exact hosts. If you use use cache with Draft Mode, confirm that your cached functions do not return draft-dependent content, which is the condition the advisory names. And if you run next dev while browsing the web, note the low-severity MCP endpoint issue (CVE-2026-94486): the dev server exposed project paths and error snippets to any website a developer visited, which is a good reminder that a local port is not a private room.

The broader habit is worth more than any single fix. Whenever a route turns input into a different format, ask three questions. What grammar does the output have, and who escapes for it? What can the fetcher reach, and does the allow-list check the destination or only the label? And what is in the cache key, compared with everything that changes the answer? Social cards are just the place where those questions got skipped this month.

Sources