Skip to main content

On 21 August we published a readiness post about a Next.js security release that had not happened yet. Vercel had pre-announced one critical severity vulnerability for 26 August, and we argued the five-day window was the whole point: inventory your applications, find the ones needing a minor upgrade rather than a patch bump, and decide who is watching on the day.

The release did not go as announced. It moved forward by a day, it contained two critical vulnerabilities rather than one, and the fix for the more serious of the two was not really a fix. It was a feature switched off. Six days later that switch was flipped back on, in two releases published one minute apart, with no security advisory attached and two materially different implementations across the two supported release lines.

TL;DR

  • Next.js 16.3.3 and 15.5.24 shipped on 25 August, a day early, fixing two critical vulnerabilities rather than the one pre-announced.
  • The headline issue (GHSA-2xp9-vwfh-vxw4, CVSS 9.5) was an unauthenticated RCE through the Image Optimization API. The bug was not in Next.js. It was a heap buffer overflow in libheif, reached through sharp, three hops below your package.json.
  • Vercel could not patch libheif, so the release disabled AVIF decoding instead. The entire fix was two lines. It did not stop your site serving AVIF; it stopped AVIF source images being optimised at all.
  • On 31 August, 16.3.4 and 15.5.25 re-enabled AVIF. 15.5.25 added a runtime check on the installed libheif version and fails closed. 16.3.4 did not. The Active LTS line got the less defensive implementation.
  • Neither release has a security advisory. The difference is visible only in the GitHub release notes and the shipped source.

What actually shipped on 25 August

Two critical issues, both unauthenticated remote code execution.

The first, CVE-2026-75604 (GHSA-p293-qw3h-jr36), affects applications using both the Pages Router and the App Router without Cache Components, where the server runs on a Windows filesystem. Linux and macOS are unaffected, and Vercel is explicit that there is no known workaround. A narrow set of deployments, but exactly the sort of box that gets stood up once, inherited twice and never migrated.

The second is the interesting one. GHSA-2xp9-vwfh-vxw4, scored 9.5 on CVSS 4.0, covers Next.js from 10.0.0 through 15.5.23 and everything before 16.3.3. An attacker-controlled AVIF image passed through the Image Optimization API could achieve remote code execution on the server, with no authentication and no user interaction.

The defect is not in Next.js. It is GHSA-g89c-p67h-r497 in libheif, scored 9.8: a heap buffer overflow in scale_nearest_neighbor() triggered by a crafted HEIF, HEIC or AVIF file, where the scaler allocates against one alpha plane’s bit depth and writes using another’s. It needs no special decoder options, and the advisory is blunt about the outcome: “We were able to get RCE using this on multiple applications.” A working proof of concept exists. The fix landed in libheif 1.23.2.

So the chain runs Next.js, to sharp, to libvips, to libheif. Vercel maintains none of the last three, which is why the fix looks the way it does.

The fix was two lines, and most of the coverage got it backwards

We diffed the shipped packages. Between 16.3.2 and 16.3.3, the server-side image optimiser changed in exactly two places. image/avif was appended to the list of content types that bypass optimisation entirely, and VipsForeignLoadHeif was removed from the allowlist of libvips loaders that Next.js explicitly unblocks.

That second line is worth pausing on, because the defence was already in place: Next.js blocks every libvips loader by default and unblocks seven by name. The HEIF loader was one of the seven, and taking it off the list was the patch.

The advisory says “optimization of AVIF files is disabled”, and much of the commentary read that as sites losing AVIF output. That is wrong, and it sends people looking for the wrong regression. We tested it with the exact allowlist 16.3.3 ships. AVIF encoding still works, because sharp.block() operates on loaders rather than savers; the transformer.avif() call was untouched, and AVIF output is opt-in anyway, since the default images.formats is ['image/webp']. AVIF decoding was blocked: feed sharp an AVIF buffer under that policy and you get “Input buffer contains unsupported image format”.

The regression is therefore about sources. If your CMS or your client’s marketing team upload AVIF originals, then from 25 to 31 August every one of those images bypassed the optimiser and was served as-is: original dimensions, original bytes, no resizing, no format negotiation. A 3 MB AVIF hero went to a phone as a 3 MB AVIF hero. That is a Core Web Vitals and bandwidth regression shipped inside a security patch, and it is invisible in a changelog diff.

One consequence outlives the window. The image cache version did not change between 16.3.3 and 16.3.4, so bypassed originals written during those six days are not invalidated by upgrading. They age out on TTL, and anything in a CDN in front of the optimiser is on its own schedule.

The revert, and the divergence nobody announced

At roughly 20:00 UTC on 31 August, Vercel published 16.3.4 and 15.5.25, one minute apart. There is no accompanying security blog post. The GitHub release notes carry the whole story, and they do not say the same thing:

  • 16.3.4: “Follow-up release to v16.3.3 re-enabling AVIF Image Optimization (#97949).”
  • 15.5.25: “re-enabling AVIF Image Optimization when newer versions of sharp are installed (#97954).”

Two pull requests, two implementations. In the source, 15.5.25 adds a function called isAvifDecodeSafe(). It reads sharp.versions.heif at runtime, requires 1.23.2 or later, and only then unblocks the HEIF loader. Its own comment states the failure mode plainly: “Custom libvips builds do not report dependency versions, so the absence of a heif version is treated as unsafe.” It fails closed, and an unsafe build simply passes AVIF through unoptimised.

16.3.4 has no such function. It reverts both lines of the 16.3.3 patch outright, unconditionally. The Maintenance LTS line got belt and braces. The Active LTS line, the one on the latest tag, got neither.

Does it matter in practice?

Usually not, and that is worth saying rather than inflating it. What protects 16.3.4 is the dependency range: sharp moved from ^0.35.3 to ^0.35.4 in its optional dependencies, and 0.35.4 is the release that bundles libheif 1.23.2. We built the awkward case to check, pinning sharp to an exact 0.35.3 in an application’s own manifest alongside [email protected]. npm nested a private 0.35.4 under next, so Next.js resolved the patched copy. The framework was safe because the package manager did the work.

The case the range cannot reach is the one 15.5.25’s comment names: sharp compiled against a system libvips rather than the prebuilt binaries. That is not exotic. It is what you get from Alpine and Debian packages, from --build-from-source, and from any Dockerfile written by someone reducing image size. The Ubuntu machine this post was written on ships libheif 1.17.6. On 16.3.4 that build gets the HEIF loader re-enabled with no version check at all, against a decoder six minor versions behind the fix.

Vercel’s own hosting is not the exposure. Self-hosted, containerised Next.js is, and that is the deployment model of most client estates we inherit.

What to do this week

  1. Get to 16.3.4 or 15.5.25. If you upgraded once in late August you are on 16.3.3 or 15.5.24: secure, but quietly not optimising AVIF sources.
  2. Print the libheif version your application actually loads, not the one you assume. From the project root: node -e "const p=require('path');const s=require(require.resolve('sharp',{paths:[p.dirname(require.resolve('next/dist/server/image-optimizer.js'))]}));console.log(s.versions.sharp,s.versions.heif)". Anything below 1.23.2, or an empty value, means the loader in 16.3.4 is unblocked against an unpatched decoder.
  3. Decide whether you build sharp against system libvips. If you do and you are on the 16.3 line, move to the prebuilt binaries or make sure AVIF never reaches the optimiser.
  4. Check what your AVIF sources did during the window. Look at actual transferred bytes for image requests rather than at your build config, and purge the CDN if the originals are still in there.
  5. Read release notes, not just the security blog. The most consequential detail in this sequence, that one line gets a runtime safety check and the other does not, exists only in two GitHub release notes and the shipped source.
  6. Name an owner for transitive depth. Nobody’s dependency inventory listed libheif. Somebody still needs to be the person who reads the upstream advisory when a framework advisory points at one.

The lesson worth keeping

In August we argued that a monthly, pre-announced security cadence breaks processes built for two emergency patches a year. This sequence shows the sharper edge of it: the pre-announcement was incomplete, the severity count changed, the date moved, the fix was a mitigation carrying a silent performance cost, and it was reverted six days later in two different ways without an advisory.

None of that is a criticism of Vercel, who moved a release forward on finding a second critical and shipped a runtime safety gate on the line where they could not guarantee the dependency. It is a description of what patching a framework now looks like. The unit of risk is no longer the framework version. It is the version of a C library four hops down, whether your build fetched a prebuilt binary or compiled against the host, and whether the release line you are on bothers to check.

Upgrading is not the same as verifying. If nobody on your team can answer “which libheif is our image optimiser loading in production” within five minutes, that is the finding, not the CVE.

REPTILEHAUS builds and maintains production Next.js applications, and we run dependency inventories and patch pipelines that go deeper than the top-level manifest. If you are not sure what your image optimiser is actually loading, or who is on the hook when the next advisory points at something you never installed on purpose, get in touch.

📷 Photo by Kammerin Hunt on Unsplash