Upgrading Payload CMS 3.85.0: from 12 audit vulns to zero
A systematic approach to eliminate every known vulnerability and replace pnpm monkey-patches with proper supply-chain configuration.
Upgrading Payload CMS 3.85.0: from 12 audit vulns to zero
I had a project running Payload CMS 3.84.1 with ten audit vulnerabilities and a .pnpmfile.cjs full of monkey-patches. The overrides in pnpm-workspace.yaml weren’t applying because --ignore-workspace blocked them, so the monkey-patches were covering for it. By the end, pnpm audit returned zero findings and the Docker build passed --frozen-lockfile.
Failure state
Three things were wrong.
1. .pnpmfile.cjs was a workaround layer. Fifteen hooks mutating transitive dependency manifests before resolution, forcing uuid, mongoose, dompurify, postcss, fast-uri into specific versions. Some of these were security patches. Others were just “latest is better” preferences. The file existed because pnpm-workspace.yaml overrides weren’t getting applied.
2. pnpm install --ignore-workspace was blocking overrides. The project runs as an isolated workspace (packages: []) inside a monorepo. The install scripts used --ignore-workspace to avoid resolving sibling projects; but this flag also prevented the project’s own pnpm-workspace.yaml from loading its overrides. The monkey-patches in .pnpmfile.cjs were covering for this.
3. Twelve audit vulnerabilities. Two came from nodemailer (SMTP command injection), eight from dompurify (XSS bypasses), one from postcss (unescaped CSS output), and one from js-cookie (prototype hijack). All were in transitive dependencies of @payloadcms/payload-cloud, monaco-editor, and next.
Remediation sequence
Step 1: Upgrade Payload CMS 3.84.1 → 3.85.0
Sixteen @payloadcms/* packages bumped. The two nodemailer CVEs vanished immediately. @payloadcms/email-nodemailer@3.85.0 ships nodemailer@8.0.10, which is past the patched threshold. No override needed.
Step 2: Remove .pnpmfile.cjs, fix the real config
@payloadcms/db-mongodb@3.85.0 ships mongoose@8.22.1 natively. All five payload packages ship uuid@13.0.2, higher than the 11.1.1 the hooks were forcing. These hacks were obsolete.
The remaining two, dompurify@3.4.0 and postcss@8.5.10, moved to pnpm-workspace.yaml overrides:
# pnpm-workspace.yaml
overrides:
dompurify: 3.4.0
postcss@<8.5.10: 8.5.10
Nine vulns eliminated here. monaco-editor@0.55.1 ships dompurify@3.2.7, but the override forces 3.4.0 across all consumers. postcss@<8.5.10 covers Next.js’s shipped 8.4.31.
Step 3: Remove @payloadcms/payload-cloud
The project uses Payload’s built-in JWT authentication: no Cognito, no email transport. Yet @payloadcms/payload-cloud was installed and importing amazon-cognito-identity-js → js-cookie@2.2.1 (vulnerable to prototype hijack). The plugin is a no-op without PAYLOAD_CLOUD=true env vars, yet it still pulls its transitive dependency tree.
Removing it deleted the last audit item and 25 transitive packages. I documented the re-enable path (SES via Nodemailer adapter, Cognito via Payload Cloud) for when email is needed later.
Step 4: Fix --ignore-workspace
Removed --ignore-workspace from both ii and reinstall scripts in package.json. The local pnpm-workspace.yaml with packages: [] is self-contained; pnpm discovers it, applies overrides, and doesn’t resolve sibling workspaces. No isolation lost, but overrides now work.
Step 5: The pnpm 11 build approval puzzle
This is where CI caught something local testing missed. Docker runs pnpm install --frozen-lockfile. With pnpm 11, packages with requiresBuild scripts (esbuild, sharp, unrs-resolver) need explicit approval, or --frozen-lockfile fails with ERR_PNPM_IGNORED_BUILDS.
The official pnpm 11 release notes say allowBuilds goes in pnpm-workspace.yaml and .npmrc is “auth/registry only.” A migration field report from DevelopersIO corroborates this; they put allowBuilds in YAML and it worked. They also hit a separate pitfall: Docker builds without ENV CI=true hang on interactive prompts when pnpm install runs (not --frozen-lockfile, which is non-interactive by design; our Dockerfile already used --frozen-lockfile, so we didn’t hit that one).
In practice, on pnpm 11.1.2, I tried every documented location:
| Location | Format | --frozen-lockfile result |
|---|---|---|
pnpm-workspace.yaml | allowBuilds: {esbuild: true} | Failed. Silently ignored |
pnpm-workspace.yaml | onlyBuiltDependencies: [...] | Failed. Silently ignored |
package.json | pnpm.onlyBuiltDependencies: [...] | Failed. Valid config but --frozen-lockfile still rejects |
.npmrc | onlyBuiltDependencies[]=esbuild | Passed |
This contradicts the official documentation. I can’t explain why allowBuilds in YAML didn’t work on this version. The DevelopersIO author got it working. The codemod migration path depends on it. It may be version-specific (11.1.2 vs later 11.x patches).
The resolution:
# .npmrc
onlyBuiltDependencies[]=esbuild
onlyBuiltDependencies[]=sharp
onlyBuiltDependencies[]=unrs-resolver
With this in place, pnpm install bakes the approval into the lockfile. --frozen-lockfile reads it back and passes.
Step 6: Verify with gitlab-ci-local
I ran the full Docker build locally using gitlab-ci-local to confirm the Dockerfile stages work end-to-end:
gitlab-ci-local --job 'app:build:production'
The pnpm install --frozen-lockfile step completed clean, the Next.js build ran, and the Docker image built successfully.
Verification
- Audit: 12 vulns → 0
.pnpmfile.cjs: deleted- Docker
--frozen-lockfile: passes - Dependency tree: 25 fewer packages (removed
@payloadcms/payload-cloud) - Overrides: all in
pnpm-workspace.yaml, all actually applied
Lessons
Test the Docker build immediately after lockfile regeneration. The pnpm install and pnpm audit passed locally, so I committed. CI caught the ERR_PNPM_IGNORED_BUILDS failure because --frozen-lockfile enforces stricter rules than a local pnpm install. A gitlab-ci-local run before pushing would have caught it on the first commit.
I’d also report the allowBuilds in YAML vs .npmrc discrepancy earlier. The official docs say one thing, the codemod says another, and pnpm 11.1.2 did a third. I burned time trying each location sequentially instead of opening an issue or searching for field reports first.
This post focused narrowly on audit cleanup: getting pnpm audit from 10 findings to zero. The broader supply-chain hardening (minimumReleaseAge, trustPolicy: no-downgrade, blockExoticSubdeps, and pnpm audit signatures) was already configured in the project’s pnpm-workspace.yaml following the framework I wrote about in Supply Chain Security with Mise and pnpm. The two pieces missing now are adding osvVulnerabilityAlerts to the update bot’s config and wrapping pnpm audit --audit-level high into a CI job that fails the pipeline on high-severity findings.
References
- pnpm 11.0 release notes. Official migration guide,
allowBuildsreplacement. - DevelopersIO: 4 Pitfalls Upgrading pnpm v10 to v11. Corroborating field report with Docker/CI fixes.
- pnpm supply-chain security docs. Overrides, trust policies,
onlyBuiltDependencies. - Payload CMS changelog. 3.85.0 release notes.
- Nodemailer GHSA-vvjj-xcjg-gr5g. SMTP command injection via CRLF in transport
nameoption, patched in 8.0.5+. - DOMPurify advisories. CVEs covering 3.1.3 through 3.4.0.
- PostCSS advisory GHSA-qx2v-qp2m-jg93. XSS via unescaped
</style>in CSS output. - gitlab-ci-local. Local GitLab CI runner for validating pipeline jobs.
This post was written with AI assistance.