← Insights

The Revert That Reopened a Vulnerability

A Next.js downgrade that fixed a broken static export also put five open security advisories back into the lockfile for fifteen hours, and nothing recorded that as a decision.

Author
Aaron Smith
Reading time
8 min

Early one morning last month I reverted the Phenom Security website from Next.js 16.1.5 back to 15.5.3. The commit subject was accurate about what it fixed: "Revert Next.js from 16.1.5 to 15.5.3 to fix static export build failure." Next.js 16 was failing to prerender /_global-error with Cannot read properties of null (reading 'useContext'), which I have from memory rather than from a log, since no build output from that night is committed anywhere. The static export produced nothing usable, and one commit made the build green again. That was the right call about the build.

It was also a decision to run a framework version carrying five open security advisories, one of them a critical remote code execution flaw, for the next fifteen hours and fifteen minutes, until a second commit that evening. Nothing recorded that as a decision. No exception, no owner, no expiry date. There was a commit message about a build.

What the revert put back

The revert changed one line in package.json, from "next": "16.1.5" to "next": "^15.5.3", and regenerated package-lock.json to resolve 15.5.3. What makes that line worth looking at is where it had been four days earlier. A commit that week had moved the same line from a pinned 15.5.3 to ^15.5.12 and updated the lockfile to install 15.5.12. So the revert did not restore the file to its previous state. It set the declared floor nine patch releases below where the repository had already put it, and the commit message says only that it fixes a build.

The repository's Dependabot alert record shows what 15.5.3 was carrying at that moment: five advisories against the package-lock.json entry for next. One critical, GHSA-9qr9-h5gf-34mp, remote code execution in the React flight protocol, first patched in 15.5.7. Two high, both denial of service, first patched in 15.5.8 and 15.5.10. Two medium, a Server Actions source code exposure patched in 15.5.8 and CVE-2025-59471, an image optimizer denial of service, patched in 15.5.10. GitHub had raised the critical one three months before, which made it over ninety days old on the morning of the revert.

What happened, in order

The sequence from merging the upgrade to fixing the version took nineteen hours, and every step in it was defensible on its own.

Offsets are from the revert itself, which is T-0.

Offset Event next in the lockfile
T-3d22h A commit raises the range to ^15.5.12, regenerates the lock 15.5.12
T-3h59m Dependabot's framework-major pull request merges, 38 days after it opened 16.1.5
T-3h58m Dependabot opens a second one, next 15.5.3 to 15.5.10 16.1.5
T-3h54m I close that second one by hand as superseded 16.1.5
T-0 The revert drops the range to ^15.5.3 to fix the export 15.5.3
T+15h15m A later commit sets ^15.5.10; five alerts recorded fixed 15.5.10

Row three is not a typo. Dependabot's own record of the repository still had next at 15.5.3, so seconds after the major merged the bot opened a pull request for the same-line patch, 15.5.10. That is the exact change made by hand nineteen hours later. The gap that matters is between rows four and five. Three hours and fifty-four minutes separate closing the pull request that contained the remedy from the commit that made the remedy necessary again.

The comment that closed the fix

Four minutes after it opened, I closed Dependabot's 15.5.10 pull request by hand, with a reason:

Closing as superseded: [the repository] is already on Next.js 16.1.5 via the merged upgrade, so this 15.5.10 bump is no longer applicable.

Three seconds later the bot answered, as it answers any closure:

OK, I won't notify you again about this release, but will get in touch when a new version is available.

Five seconds after that I added a clarification saying the repository "already runs Next.js 16.1.5" and that the upgrade path was "superseded". The bot read neither comment. Its reply fires on any closure, and its promise not to raise that release again is one it kept.

Three phrases in my own comments carry the damage. "Runs" is present tense, and it stayed true for three hours and fifty-four minutes. "Superseded" and "no longer applicable" both describe a relationship between two pull requests rather than between a version and an advisory; 15.5.10 was superseded as a diff and never as a remedy. The bot's reply also offered the way out: "If you change your mind, just re-open this PR and I'll resolve any conflicts on it." Reopening it at the moment of the revert would have been one click. Instead the identical change was typed by hand fifteen hours later.

The build hands you a target, the advisory does not

Reverting to green. When a dependency upgrade breaks the build, you roll the version back to the last one the build is known to have passed on, rather than forward to the lowest version that both builds and carries the patch. It persists because the build hands you a target for free. Git knows precisely which version was green and the name is sitting in the diff you are already reverting; the advisory database knows which version is safe, and reaching it requires a second tool and a second decision. Under time pressure only one of those two is in the terminal you have open, which is why the reverted range here was ^15.5.3 and not ^15.5.12.

The cheapest thing that breaks the pattern is making the revert take its target from the advisory rather than from git history. One query against the repository's own alert list returns the first patched version for every open advisory on that package, and the highest of those is the floor a revert must not go below. Sometimes that version will not build either. That is a useful outcome, because at that point you know you are accepting risk while the build failure is still in front of you, which is the only moment anyone will write it down.

Security patches and framework majors need separate lanes

This repository has never had a .github/dependabot.yml. Not on the day of the revert, not now. Every one of those pull requests was a Dependabot security update, which GitHub raises with no configuration file at all, and they arrived in one undifferentiated stream: a framework major, two tar bumps, an ajv bump, a minimatch bump and two js-yaml bumps, seven of them merged inside six minutes that evening. The framework major and the tar patches were the same kind of object as far as the review was concerned, and only one of them could break a build.

The configuration that separates them is short:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: "/"
    schedule:
      interval: daily
    groups:
      security-patches:
        applies-to: security-updates
        update-types: ["patch", "minor"]
    ignore:
      - dependency-name: "next"
        update-types: ["version-update:semver-major"]

The groups block batches same-line security patches into one reviewable pull request. The ignore block keeps routine major upgrades out of the everyday stream, and it does not do the thing you probably want it to do. GitHub's Dependabot options reference states that inside an ignore block, "the update-types option only affects version updates, not security updates." An advisory whose lowest patched version happens to be a major release will still arrive as a major-version pull request, exactly as the framework upgrade did. Configuration gets you the lane. Keeping a broken major from blocking a patch is a rule people follow, not a key you can set.

One part of this I cannot explain. The npm registry shows 16.1.5 and 15.5.10 published seven minutes apart on the same day, both carrying the same fixes. GitHub documents that a security update raises a pull request "to update the dependency to the minimum version that includes the patch." The pull request I received named the major.

None of this was reachable from the internet

The strongest objection is that none of this was exploitable. The site is a static export: next.config.ts sets output: 'export' with images: { unoptimized: true }, the build emits HTML and assets that are synced to object storage, and no Next.js server process runs in production. Remote code execution in the React flight protocol needs a server speaking that protocol. A denial of service in the image optimizer needs the image optimizer to be running. For those fifteen hours the internet-facing risk was close to zero, and that reading is correct.

Two things survive it. The vulnerable framework still executed on the build host every time npm run build ran during the window, and on this project that host is a laptop holding credentials for the production bucket, which is a smaller blast radius than a public server and not a trivial one. More importantly, nobody made this argument on the night itself. The revert was decided on build status, and the reachability analysis that justifies it was constructed days later, by me, while writing this post. An argument that only shows up afterwards is a rationalization, not a control.

What I still have wrong

The commit that ended this set the range to ^15.5.10. The repository had declared ^15.5.12 five days before. Nothing is exposed by that, since the lockfile pins 15.5.10 and 15.5.10 carries all five patches, but the declared floor now sits two releases below where the same file stood five days earlier, and I found that out by writing this post rather than by any control. It is the same failure as the revert, made twice in one weekend from the same cause: the version I typed came from the artifact in front of me instead of from the history of the line I was editing.

The rule I take from this: do not close a Dependabot pull request that still contains a fix you need, even when the manifest has moved past it, because an open pull request survives a revert and a closed one does not. The boundary is that this only works while the noise is small enough to notice. A repository absorbing twenty security pull requests a week will bury the one that matters, and at that point you need the lanes to sort them, which is a different problem than the one that cost me fifteen hours in the small hours of the morning.