The pnpm Override That Silently Defeated a Security Bump

This started as a chore and turned into two separate lessons about how pnpm decides what actually lands in node_modules. Neither lesson is in the error output, because there is no error output. Everything succeeds. That's the problem.

The chore: one real signal in 166 findings

Our monorepo had a weekly scheduled audit workflow that failed every single Sunday. Not "sometimes red" — unconditionally red, because pnpm audit --audit-level=moderate across an 18-package workspace with 40+ example apps finds 166 vulnerabilities (12 low / 68 moderate / 79 high / 7 critical), most of them in dev dependencies and dormant packages nobody ships. A check that is always red is not a check; it's alarm fatigue with a cron schedule. I deleted the workflow and did the triage by hand instead, filtering the audit JSON by which workspace package each finding's path actually starts at.

The filtered picture was completely different from the scary total. Nearly every critical and high lived in example apps and packages with zero consumers. Exactly one published package had real production-dependency advisories: our email package, with

  • handlebars ≤4.7.8 — a critical JavaScript-injection via AST type confusion (GHSA-2w6w-674q-4c4q), patched in 4.7.9, and
  • nodemailer — a high: the message-level raw option bypassed disableFileAccess/disableUrlAccess, enabling arbitrary file read and SSRF in the delivered message (GHSA-p6gq-j5cr-w38f), patched only in 9.0.1.

So: bump handlebars to ^4.7.9, bump nodemailer two majors to ^9.0.3, bump resend to the latest 6.x while we're in there. Edit the package.json, run pnpm install, done in 2.7 seconds.

$ node -e "console.log(require('nodemailer/package.json').version)"
7.0.11

The package.json says ^9.0.3. The install said Done. The tree says 7.

Why the install kept the old version

The answer was sitting in line 18 of the lockfile:

pnpm-lock.yaml
overrides:
  glob: '>=10.5.0'
  body-parser: '>=2.2.1'
  esbuild: '>=0.25.0'
  nodemailer: '>=7.0.7'

Months earlier, someone (me) had added a root pnpm.overrides floor for nodemailer — the standard move when a transitive dependency somewhere deep in the tree has an advisory and you can't wait for every intermediate package to update. Floors like glob: '>=10.5.0' are exactly right for that.

But pnpm overrides don't combine with a package's declared range. They replace it, everywhere in the workspace. My freshly-edited ^9.0.3 was never consulted; the effective range was >=7.0.7, and since the lockfile already had a resolution that satisfied it — 7.0.11 — pnpm correctly changed nothing. No warning, no conflict, nothing to see. Every tool did its job.

The fix is one character class: the override becomes ^9.0.3, matching the range the package itself declares. And that's the first lesson, because the failure mode here is nastier than "old version": the published tarball's package.json said ^9.0.3 all along. Overrides are a workspace-local concept — they don't ship. A buyer installing the package from the registry would have gotten nodemailer 9. It was my own workspace — the place where the tests run — that was quietly resolving 7. The artifact was right and the CI was lying, which is the worse direction.

Rule of thumb I've adopted: >= floors are for transitive phantoms you don't declare; the moment an override covers a direct dependency of a package you publish, the override must be the same range the package declares, or your test matrix and your users' installs diverge silently.

The dependency that outlived its own removal

With nodemailer sorted, the audit for the email package was still not clean. The remaining highs all rode one path:

packages/email > resend@6.18.0 > @react-email/render@0.0.16 > js-beautify@1.15.4 > ...

Which is strange, because:

$ npm view resend@6.18.0 dependencies
{ "postal-mime": "2.7.5", "standardwebhooks": "1.0.0" }

resend 6.18.0 does not depend on @react-email/render at all. It moved it to an optional peer with range * versions ago. So why is a 0.0.16 copy — old enough to drag in a js-beautify chain with js-cookie, minimatch, and brace-expansion advisories — still in my tree?

Lockfile inertia, again, wearing a different hat. Back when we first installed resend 6.5.2, @react-email/render@0.0.16 genuinely was in its dependency tree, and with autoInstallPeers: true it stayed installed when resend later demoted it to an optional peer. From then on, every pnpm install looked at the peer range *, found that the existing 0.0.16 resolution satisfied it (everything satisfies *), and kept it. pnpm update -r @react-email/render refreshed its inner dependencies and still wouldn't move the package itself — peers don't update like dependencies. The fossil was load-bearing to nobody and immovable by the normal tools.

The only thing that forced re-resolution was, ironically, the same mechanism that caused problem one — a root override:

package.json (workspace root)
"pnpm": {
  "overrides": {
    "@react-email/render": ">=2.1.0"
  }
}

One install later, render resolves to 2.1.0 (whose dependency tree contains no js-beautify), the whole vulnerable chain evaporates, and the email package's production audit reads zero findings — down from 11 with a critical.

And note the asymmetry with lesson one: here the >= floor shape is correct, because @react-email/render is precisely a transitive phantom — nothing we publish declares it, so there is no shipped range to diverge from.

What actually changed

For the record, the whole remediation was four range bumps and two override edits — no source changes:

packages/email/package.json
-    "handlebars": "^4.7.8",
-    "nodemailer": "^7.0.7",
+    "handlebars": "^4.7.9",
+    "nodemailer": "^9.0.3",
-    "resend": "^6.0.0"
+    "resend": "^6.18.0"

Plus the real work, which was verifying the two nodemailer majors against the package's actual surface (the only breaking changes turned out to be an error code rename we never referenced, and TLS-validation-by-default on remote content fetches — a behavior change we documented as such and released as a major of our own).

What are the takeaways?

  • An always-red check is worse than no check. Delete it or scope it; don't let it train everyone to ignore red.
  • Audit totals are noise; audit paths are signal. Group findings by which package's production tree they actually reach before reacting.
  • pnpm overrides replace declared ranges, workspace-wide. A floor you added a year ago will silently eat the range bump you make today. After any security bump, verify the resolved version (node -e "require(...)"), never just the range you wrote.
  • Overrides don't ship. If an override covers a direct dependency of a published package, mismatched ranges mean your CI tests one version and your users install another. Match the shipped caret.
  • Optional peers + autoInstallPeers + an old lockfile can preserve a dependency the ecosystem already removed. If an audit path runs through a package that shouldn't exist, check whether it's a stale peer resolution — and evict it with an override, because install and update won't.