The Same CVE on Both Sides of the Arrow

lodash 4.17.21 -> 4.18.1 (code injection via _.template, CVE-2021-23337)

I wrote that line five times in one evening, and every time the same CVE number sat on both sides of the arrow.

4.17.21 is not an ordinary version number. For years it was the answer. It is the release that fixed CVE-2021-23337, and if you have ever run an audit against a JavaScript project you have seen 4.17.21 as the destination: get here and the finding goes away. It was also, for a long stretch, the newest lodash in existence. Sitting on it was not a compromise. It was the end of the line.

The advisory now names 4.18.0 as the fixed release. _.template compiles its input into a function, and there was still a way out of that sandbox through a crafted options object, so the version that closed the hole is the version you have to leave to close the hole.

I assumed something was holding me there

Five projects stuck on an old copy means five projects with something in the way: a peer dependency with an opinion, a transitive range someone else froze, a resolution I would have to fight for.

The declared range was ^4.17.21.

A caret range permits any 4.x at or above its floor. ^4.17.21 had been permitting 4.18.x since the moment 4.18.0 was published. Nothing in the project was refusing the upgrade. There was no argument to win, no override to force, no maintainer to email. The lockfile was repeating an answer it had been handed once, and nobody had put the question again.

The range is the file with an opinion about the future. The lockfile is the file with a memory. Installs read the memory.

I know that. Everyone knows that. It is the entire advertised purpose of a lockfile, and I still spent the first ten minutes looking for a villain, because “reproducible” is a word I had filed under good and had stopped reading as what it says. It says: this build will keep making the same decision, including the ones that have since become wrong.

In one of the five there genuinely was nothing of mine to raise. lodash is not declared there and no source file imports it. Every copy arrives underneath: a test matcher, a webpack plugin chain, a service worker builder, a URL parser inside the DOM shim. That one needed an npm overrides entry, which is a different kind of sentence. A dependency range says what I need. An override says to disregard what everyone else asked for. The more aggressive move was the only one available, because I had never expressed a preference about this package to begin with.

The other freeze

Later the same evening, the deploy workflow for this site.

Its actions were pinned to commit SHAs. That is correct advice and I would give it again to anyone. A tag is a movable label: @v4 resolves to whatever the maintainer most recently decided v4 means, and if that account is compromised, to whatever someone else decides it means. A commit SHA cannot be repointed. Pinning to one is how you stop a supply chain from changing underneath a workflow that holds your deploy credentials.

It is also how you stop receiving fixes.

All four pins had drifted behind their release lines. Checkout and the Node setup action were three majors back. The pnpm setup action was two majors back while the workflow asked it for a pnpm version newer than anything that pinned release knew pnpm would reach. The Cloudflare deploy action was one major back, and that is the step holding the token that publishes this page.

Nothing broke in. Four dependencies aged quietly, in the one file where I had written down, deliberately and correctly, that nothing was ever to change.

The comment could not go stale

Here is how the line read:

- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

A SHA is unreadable by design, so the comment is the only part of that line a human actually parses. And # v4 is true forever. It was true the day it was written. It is true three majors later. It will be true when the pin is five years old, because major 4 is precisely what that commit is going to be for the rest of time.

Here is how it reads now:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Two things changed on that line. The SHA moved, which is the edit that changes what runs. The comment went from a family to a point, which changes nothing that runs at all, and that is the edit I would keep if I could only keep one. # v4 is a category, and a category cannot be stale. # v7.0.1 is a claim about a moment, and a claim about a moment can be checked against a release page in four seconds by someone who is not even looking for trouble.

Every pin I own is a decision plus a date, and both storage formats keep the decision and throw the date away.

The caret is back in package.json, ^4.18.1 now, cheerfully permitting every 4.x that has not been written yet. The lockfile beside it holds 4.18.1 and will go on holding it until somebody comes and asks again. I spent a while deciding which of those two files is the optimistic one, and I have settled it: the optimism lives entirely in the file that installs do not read.


← all writing