Defense-in-depth: require own-property transport to skip prototype-pollution gadget - #2451
Defense-in-depth: require own-property transport to skip prototype-pollution gadget#2451ridingsa wants to merge 2 commits into
transport to skip prototype-pollution gadget#2451Conversation
…erited
Single own-property check at the option-read site that flows into the
thread-stream worker. Skips the transport branch when `opts.transport`
is inherited via the prototype chain rather than set as an own property.
Threat-model fit per the GHSA-7f85-5jq4-96m8 reply: pino doesn't promise
to defend against active prototype pollution. This change doesn't claim
to — it just adds one cheap check at the one read site that ends up
calling `new ThreadStream({worker: target})`, since that's the read where
a polluted value would have outsized impact.
Verified locally against pinojs/pino@0a10b332:
- `npm test`: 531 tests, 527 pass, 0 fail
- pino({transport: {target: <legit>}}): worker spawns as before
- with Object.prototype.transport polluted, pino() (no opts) no longer
spawns the worker
mcollina
left a comment
There was a problem hiding this comment.
Thanks for opening a PR! Can you please add a unit test?
Per maintainer review on pinojs#2451 — adds a unit test that proves the own-property check at lib/tools.js:337 closes the gadget. The fixture transport-pp-gadget-sentinel.mjs is a valid pino transport that writes a sentinel file at import time. With Object.prototype.transport polluted, calling pino() with no own transport must NOT spawn the worker and must NOT load the attacker module — assertion: sentinel file does not exist after a 300ms grace window. Companion test confirms own-property transport still works under the same polluted prototype (no regression on the normal usage path). Verified locally: - patched (Object.hasOwn check present): both tests pass - patch reverted (one-liner removed): the gadget-closed test fails with 'attacker module was imported via the prototype-polluted transport gadget' (actual: true, expected: false)
|
Added the regression test in b6b0cd6 (b6b0cd6) — test/transport/protect-from-prototype-pollution.test.js plus a small fixture at test/fixtures/transport-pp-gadget-sentinel.mjs. Two cases: (1) with Object.prototype.transport = {target: <attacker.mjs>} polluted and pino() called with no own transport, asserts the attacker module is never imported (sentinel file never created within a 300 ms grace window) — gadget-closed. (2) Same polluted prototype, explicit pino({transport: {target: }}), asserts the legit transport still spawns and receives the log line — no regression on the normal path. Verified locally: patched, both pass in ~450 ms; patch reverted, test #1 fails with AssertionError: attacker module was imported via the prototype-polluted transport gadget (actual: true, expected: false) and test #2 still passes — confirms the signal is specific to the inherited-transport path. Lint clean. Uses node:test/node:assert to match the rest of test/transport/. |
|
Ci is very red |
|
Passing by on the "CI is very red" note, since this one is approved and only the checks are holding it. I measured it, and the red does not look like it belongs to this branch. The failing run predates the fix for the test that fails in it. The last run here is Two unrelated branches failed the same way in the same window, down to the 11 of 13 job count:
That test was fixed on main by #2470 ( Rebased on current main, the suite matches main. I cherry-picked
The difference is exactly the two tests this PR adds, and both pass. The single failure shows up on unmodified main too, so it is my platform rather than this branch (it is the Windows transpile path that #2499 is about). The regression test does measure the change. On the rebased tree, So the assertion is tied to the One practical note: re-running the old workflow would replay the recorded merge commit, which still lacks #2470. Pushing a rebase onto current main is the surer way to get a green board. @ridingsa, if it helps, I am happy to hand you the rebased branch or open it as a PR against your fork. |
|
The same tests pass on main. |
What
One-line change at
lib/tools.js:337— gate theopts.transportbranch onObject.hasOwn(opts, 'transport'). Whenoptsdoesn't have its owntransportkey, skip the transport-loading branch instead of reading the value through the prototype chain.Why
Per your reply on GHSA-7f85-5jq4-96m8, pino's threat model assumes no active prototype pollution on the process. That's fair — Node itself doesn't harden against PP, so libraries can't promise to either. This PR isn't asking pino to make that guarantee; it's accepting the existing threat model and adding a single own-property check at the place that triggers a dynamic
import()viathread-stream.The change costs one method call per
pino()invocation (Object.hasOwn is V8-fast-path) and removes the one read site where a pollutedObject.prototype.transportvalue would reach an import path.What it does NOT do
opts.*reads in this function still inherit from the prototype.pino({transport:...}),opts.transportis an own-property andhasOwnreturns true.Test coverage
Verified locally against
pino@main(0a10b332):npm test— 531 tests, 527 pass, 0 fail. One flaky retry onthread-stream async flush should call the passed callback; runner counted it 0-fail. Unrelated to the transport-read site.Object.prototype.transport = {target:'./evil.mjs'}polluted, callingpino()no longer spawns the worker → no attacker module loaded → no sentinel file created. (PoC from GHSA-7f85-5jq4-96m8 reproduced under the patched source.)pino({transport: {target: <legit>}})still spawns the worker and reaches the user-supplied transport module. Own-propertytransportpasseshasOwn, branch fires as before.tools.jslines: 97.65% pre-patch and post-patch; the patched line (337) sits in the already-covered region.Happy to add an explicit regression test for the polluted-prototype case if you'd like —
test/transport/transport.test.jswould be the natural home. Drop the PoC in atap.test('pino() ignores inherited Object.prototype.transport', ...)block.Why this read site specifically
Of the 5 reads of
opts.transportinnormalizeArgs, this is the one that flows intotransport({...opts.transport, ...})which callsnew ThreadStream({worker: target})and ends up atworker_threads.Worker+ dynamicimport(). The other reads (opts && opts.transporttruthy check at L330,instanceof SonicBoomat L338,.targets/.writable/._writableStatechecks) either throw an error on the polluted shape or short-circuit harmlessly. Hardening just the dangerous one keeps the patch minimal.