Skip to content

CVE-2026-104849: Tinypool: Prototype Pollution Gadget to RCE in run() options - #50

Merged
SeriousJul merged 3 commits into
mainfrom
factory/59-cve-2026-104849-tinypool-prototype-pollution-gadget-to-rce-in-run-options
Oct 6, 2026
Merged

SeriousJul merged 3 commits into
mainfrom
factory/59-cve-2026-104849-tinypool-prototype-pollution-gadget-to-rce-in-run-options

Conversation

@SeriousJul

Copy link
Copy Markdown
Owner

https://github.com/SeriousJul/seriousjul.github.io/security/dependabot/59

Package: tinypool
Ecosystem: npm
Vulnerable range: < 2.1.2
Severity: critical
CVSS score: 0

tinypool is a fork of piscina and inherited the same prototype-pollution surface. When pool.run(task, options) is called, the filename option is read from the provided options object. If that object does not have an own filename property, the lookup falls through to Object.prototype.

An attacker who can pollute Object.prototype.filename (for example, via a vulnerable lodash.merge, qs.parse, or similar elsewhere in the application) can make tinypool load and execute an attacker-controlled worker module.

This is the tinypool counterpart to the piscina root discovery GHSA-x9g3-xrwr-cwfg.

pool.run(task) with no second argument is not affected, because kDefaultOptions.filename is null and the options object is not user-controlled. The exploit only triggers when the caller passes their own options object to pool.run().

Impact

Arbitrary JavaScript execution in the worker pool. If the application passes attacker-controlled data as the run() task and also supplies a run() options object, the attacker can redirect execution to a malicious worker that exfiltrates or modifies that data, achieving remote code execution and/or data exfiltration.

Proof of Concept

// legitimate-worker.mjs
export default async (task) => ({ by: 'legitimate-worker', processed: task })

// malicious-worker.mjs
export default async (task) => ({ by: 'attacker', stolenRequestBody: task })

// main.js
import express from "express";
import Tinypool from "tinypool";
import { fileURLToPath } from "node:url";
import path from "node:path";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

// Simulate upstream prototype pollution (lodash merge, qs parse, etc.)
Object.prototype.filename = path.join(__dirname, "malicious-worker.mjs");

const pool = new Tinypool({
  filename: path.join(__dirname, "legitimate-worker.mjs"),
});

express()
  .use(express.json())
  .post("/", async (req, res) => {
    const ac = new AbortController();
    const result = await pool.run(req.body, { signal: ac.signal });
    res.json(result);
  })
  .listen(31337);

Suggested fix

Read all user-supplied options from own properties only (Object.hasOwn or Object.prototype.hasOwnProperty.call) and build the internal ThreadPool.options object with a null prototype.

tinypool 1.1.1 (transitive dep of @docusaurus/core 3.10.2) is
vulnerable to prototype pollution to RCE via pool.run() options.
No stable docusaurus release depends on patched tinypool, and the
declared range ^1.0.2 cannot reach 2.x, so force tinypool 2.1.2
(first patched version) with an npm override.

Verified: docusaurus SSG thread pool works with 2.1.2
(builds pass with DOCUSAURUS_SSG_WORKER_THREAD_COUNT=2), npm test
91/91 pass, typecheck clean.

Alert: https://github.com/SeriousJul/seriousjul.github.io/security/dependabot/59
@SeriousJul
SeriousJul marked this pull request as ready for review October 6, 2026 22:40
@SeriousJul

Copy link
Copy Markdown
Owner Author

Score: 85 / 100

Specification Check: Pass

  • Vulnerable tinypool (< 2.1.2) is out of the tree. npm ci installs tinypool 2.1.2, confirmed.
  • The advisory PoC is inert against the installed 2.1.2. With Object.prototype.filename polluted, pool.run(task, { signal }) still goes to the legitimate worker.
  • The same PoC succeeds against the previously locked 1.1.1: the attacker worker receives the task data. Red state confirmed.
  • Site stays green: typecheck clean, npm test 91/91 pass, npm run build succeeds with DOCUSAURUS_SSG_WORKER_THREAD_COUNT=2 (thread pool actually exercised).
  • The cross-major override (^1.0.2 declared, 2.1.2 forced) is API-safe: every option the @docusaurus/core SSG executor passes (filename, minThreads, maxThreads, concurrentTasksPerWorker, runtime, isolateWorkers, workerData, maxMemoryLimitBeforeRecycle, resourceLimits) is supported in the 2.1.2 typings, and the default export that ssgExecutor.js imports is present.

Quality Check: Pass, one gap

Required Changes:

  • Add tests/tinypool-security.test.mjs following the existing pattern: pollute Object.prototype.filename, call pool.run(task, { signal }), assert the legitimate worker responds. It must be red on tinypool 1.1.1 and green on 2.1.2. Both states are reproducible with the PoC from the advisory.

@SeriousJul

Copy link
Copy Markdown
Owner Author

Added the missing regression test from the review.

Changes (commit 2b5fa56)

  • tests/tinypool-security.test.mjs - new regression test following the pattern of the prior CVE pins.
  • tests/fixtures/tinypool/legitimate-worker.mjs, tests/fixtures/tinypool/malicious-worker.mjs - fixture workers that report which worker answered, so a redirection is visible from the response alone.

What the test checks

  • Behaviour: pollutes Object.prototype.filename with the malicious fixture, calls pool.run(task, { signal }) in the exact advisory shape, and asserts the response comes from the legitimate worker with the task data intact. Includes the clean-prototype control, the not-affected pool.run(task) shape, and a negative control proving an explicit own filename option still routes on purpose.
  • Lockfile: version floor at 2.1.2, exactly one flat copy, and the cross-major override in package.json (the ^1.0.2 range from @docusaurus/core cannot admit 2.1.2 on its own, so the override must exist and sit at or above the patched floor).

Both states measured, as the review asked

  • tinypool 1.1.1 in a resolved tree: fails 3 of 7. The advisory-shape case fails with the task answered by the attacker worker ({"by":"attacker","stolen":...}), plus the two version-floor checks.
  • tinypool 2.1.2 (this branch): passes 7 of 7.

Suite status after the change

  • npm test: 98/98 pass (was 91, +7 from this file).
  • npm run typecheck: clean.

@SeriousJul SeriousJul left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Score: 94/100

Specification check: Pass

  • Alert 59 (tinypool < 2.1.2, transitive via @docusaurus/core) is resolved. npm ci installs exactly tinypool 2.1.2, and npm audit no longer flags tinypool.
  • The cross-major override is the right mechanism. @docusaurus/core declares ^1.0.2, which cannot reach 2.1.2, and no stable docusaurus release depends on a patched tinypool. The commit message states this.
  • API compatibility verified. Every option docusaurus passes to the Tinypool constructor (filename, minThreads, maxThreads, concurrentTasksPerWorker, runtime, isolateWorkers, workerData, maxMemoryLimitBeforeRecycle) still exists in 2.1.2.
  • End-to-end verified. npm run build passes with DOCUSAURUS_SSG_WORKER_THREAD_COUNT=2, which forces the pooled SSG executor. That executor is the only tinypool consumer in the tree.
  • The regression test is empirically red on 1.1.1: with Object.prototype.filename polluted, run(task, { signal }) redirects the task to the attacker worker, exactly the advisory shape. On 2.1.2 all 7 tests pass. With tinypool absent, the suites skip with a reason and the exit code is 0.
  • Full suite: 98/98 pass. Typecheck clean.

Quality check: Pass

  • Strong test design. The behaviour suite separates the vulnerable shape (caller-supplied options with no own filename) from the safe no-options shape and the documented-API shape (explicit own filename as negative control). The lockfile suite pins the version floor, asserts one flat copy matching the installed tree, and asserts the override exists and sits at or above the floor.
  • No new dependencies. The test uses node:test. It runs in CI (ci-cd.yml runs npm test on Node 20.x, which satisfies the 2.1.2 engines field).
  • The large lockfile diff against main comes from the stacked PRs #48/#49. The two commits of this PR change only the tinypool lock entry, the override, and the test files.

Non-blocking notes:

  • satisfies() reads an exact range (for example 1.0.2) as a floor. The correct semantics for an exact range is equality. No impact in this tree, because only ^1.0.2 occurs.
  • When tinypool is absent, the lockfile-only checks skip too, although they read only JSON files and need no installed package. This is a documented tradeoff, and it is acceptable.
  • The exact pin to 2.1.2 needs a dependabot or manual bump when tinypool ships a 2.x security fix. Version 2.2.0 already exists. Pining the first patched version is acceptable for a security fix.

Required changes: none

@SeriousJul
SeriousJul merged commit 2693d65 into main Oct 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant