Repository navigation
CVE-2026-104848: Tinypool: Prototype Pollution gadget in worker options leads to Remote Code Execution - #53
Conversation
… pin The pin to tinypool 2.1.2 (added for CVE-2026-104849) already leaves the vulnerable range <= 2.1.0 of CVE-2026-104848, whose first patched version is 2.1.1. This adds the behavioural coverage for this advisory's worker-options gadget, which the existing suite did not exercise: - Object.prototype.execArgv pollution must not make pool workers load an attacker module (the advisory's proof of concept) - Object.prototype.env pollution must not replace the worker environment, whose NODE_OPTIONS the worker thread re-parses at startup - an explicit own execArgv/env option must still be honoured (negative control: own options and polluted ones stay distinguishable) Verified: 1.1.1 fails 5 of 10 (advisory-shape run case, the two lockfile floor checks, and both new worker-options cases); 2.1.2 passes 10 of 10. npm test 101/101, typecheck and prettier clean. Alert: https://github.com/SeriousJul/seriousjul.github.io/security/dependabot/60
|
No dependency change is needed on this branch: the tinypool pin that resolves this alert (range This branch adds the regression coverage the existing suite was missing for this advisory's worker-options gadget:
Verified red/green: the suite fails 5 of 10 on tinypool 1.1.1 (advisory-shape run case, the two lockfile floor checks, and both new worker-options cases) and passes 10 of 10 on 2.1.2. Full suite 101/101, typecheck and prettier clean. |
SeriousJul
left a comment
There was a problem hiding this comment.
- Score: 95 / 100
- Specification Check: Pass
- Quality Check: Pass
- Required Changes: None blocking. Two optional notes listed below.
Verification performed
I ran the suite against all three states the file documents, on Node 20 (the CI runtime) and Node 26:
| tinypool | Result |
|---|---|
| 2.1.2 (pinned, patched) | 10/10 pass |
| 2.1.0 (vulnerable boundary) | 4 fail: version floor, advisory-shape run(), polluted execArgv, polluted env |
1.1.1 (version the unsatisfied ^1.0.2 range ships) |
5 fail of 10, exactly as the file header documents |
The red state is real, not vacuous: on 2.1.0 the execArgv test observed the payload write its marker file, and the env test observed the worker carrying the polluted marker and no PATH.
I also confirmed the env vector is genuine RCE, not just environment replacement: a polluted Object.prototype.env with NODE_OPTIONS: '--require <payload>' made the worker load the attacker module on both Node 20.20.2 and Node 26.8.2. The comment in the env test that "the worker thread re-parses NODE_OPTIONS from its own environment at startup" is accurate for both CI and local runtimes.
Specification
Both advisory vectors are covered end to end:
execArgv- the advisory's proof of concept, reproduced with the pool constructed without an ownexecArgv, asserts the worker stayed legitimate and the marker file was never written.env- asserts the worker saw no polluted marker, still had the hostPATH, and theNODE_OPTIONSpayload never ran.- A negative control proves an explicitly passed own
execArgv/envstill loads the named module, so the red cases cannot pass for the wrong reason.
The pin floor correctly stays at 2.1.2 (the higher of 2.1.1 and 2.1.2), and the lockfile override checks are unchanged and still hold. The fixture is documented and matches the style of the existing fixture pair. Prettier is clean.
Quality
- The
withPrototypeFilenamehelper generalised towithPrototypeProperty(key, value, body)with the same collision guard and cleanup; no duplication added. - Temp payload and marker live in a
mkdtempscratch dir removed in afinally, which is tighter than the advisory's fixed/tmp/RCE_PROOF.txtpath. - The header's verified-behaviour table is accurate: I reproduced the "5 of 10" figure for 1.1.1 exactly.
Optional notes (not blocking)
- The header documents 1.1.1 and 2.1.2 only. Adding the 2.1.0 boundary row (4 of 10 fail) would make the vulnerable range
< 2.1.1visible in the file, since 1.1.1 alone does not prove the fix landed at 2.1.1. - The env test asserts
marker === nullbeforehasPath. On a vulnerable build both fire, but if a future regression passes a polluted env that does carryPATH, only the marker check catches it. The current ordering and wording are fine; no change needed.
https://github.com/SeriousJul/seriousjul.github.io/security/dependabot/60
Package: tinypool
Ecosystem: npm
Vulnerable range: <= 2.1.0
Severity: critical
CVSS score: 0
tinypoolpasses worker options tonew Worker()by reading them off a plain object whose prototype isObject.prototype. Options the application did not set are resolved through the prototype chain and then passed explicitly toworker_threads.Worker.Node core ignores
Workeroptions inherited fromObject.prototype. By reading them and passing them explicitly, tinypool re-materialises them as own properties and defeats that protection.Two keys reach code execution:
execArgv— pollutingObject.prototype.execArgv = ['--require', '/path/to/attacker.js']causes every pool worker to load the attacker's script.env— pollutingObject.prototype.env = { NODE_OPTIONS: '--require /path/to/attacker.js' }achieves the same via environment injection.Root cause
dist/index.jslines 508-511:this.optionsis built at line 470 via object spread:Impact
Arbitrary code execution inside every worker the pool spawns, with the privileges of the host process. Because tinypool is the worker pool behind Vitest (~42M downloads/week), the natural blast radius is developer machines and CI runners — an attacker who lands a prototype-pollution primitive anywhere in the dependency tree gets code execution in the build/test pipeline, which is a supply-chain foothold (access to CI secrets, signing keys, artifact publishing).
Proof of concept
Minimal reproduction (3 files):
worker.mjs — the application's own legitimate worker:
payload.js — attacker-controlled code (never referenced by the app):
app.js — normal tinypool usage:
Run:
Both
execArgvandenvvectors confirmed on Node 20.Suggested fix
Resolve worker options with own-property semantics: