Skip to content

Defense-in-depth: require own-property transport to skip prototype-pollution gadget - #2451

Open
ridingsa wants to merge 2 commits into
pinojs:mainfrom
ridingsa:harden-transport-own-property
Open

Defense-in-depth: require own-property transport to skip prototype-pollution gadget#2451
ridingsa wants to merge 2 commits into
pinojs:mainfrom
ridingsa:harden-transport-own-property

Conversation

@ridingsa

Copy link
Copy Markdown

What

One-line change at lib/tools.js:337 — gate the opts.transport branch on Object.hasOwn(opts, 'transport'). When opts doesn't have its own transport key, 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() via thread-stream.

The change costs one method call per pino() invocation (Object.hasOwn is V8-fast-path) and removes the one read site where a polluted Object.prototype.transport value would reach an import path.

What it does NOT do

  • Doesn't claim to make pino "PP-safe". Any of the other dozen opts.* reads in this function still inherit from the prototype.
  • Doesn't change behavior for any normal caller — if you pass pino({transport:...}), opts.transport is an own-property and hasOwn returns true.
  • Doesn't add a dependency or a sanitization layer.

Test coverage

Verified locally against pino@main (0a10b332):

  • npm test — 531 tests, 527 pass, 0 fail. One flaky retry on thread-stream async flush should call the passed callback; runner counted it 0-fail. Unrelated to the transport-read site.
  • Gadget closed — with Object.prototype.transport = {target:'./evil.mjs'} polluted, calling pino() no longer spawns the worker → no attacker module loaded → no sentinel file created. (PoC from GHSA-7f85-5jq4-96m8 reproduced under the patched source.)
  • Normal usage preservedpino({transport: {target: <legit>}}) still spawns the worker and reaches the user-supplied transport module. Own-property transport passes hasOwn, branch fires as before.
  • Coveragetools.js lines: 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.js would be the natural home. Drop the PoC in a tap.test('pino() ignores inherited Object.prototype.transport', ...) block.

Why this read site specifically

Of the 5 reads of opts.transport in normalizeArgs, this is the one that flows into transport({...opts.transport, ...}) which calls new ThreadStream({worker: target}) and ends up at worker_threads.Worker + dynamic import(). The other reads (opts && opts.transport truthy check at L330, instanceof SonicBoom at L338, .targets/.writable/._writableState checks) either throw an error on the polluted shape or short-circuit harmlessly. Hardening just the dangerous one keeps the patch minimal.

…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 mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)
@ridingsa

Copy link
Copy Markdown
Author

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/.

@mcollina mcollina left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

lgtm

@mcollina

mcollina commented Jul 6, 2026

Copy link
Copy Markdown
Member

Ci is very red

@luantaraschi

Copy link
Copy Markdown

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 27361795864, on head b6b0cd6 over base 0a10b33 (June 11). Eleven of its thirteen jobs fail, and every one of them fails on the same single test:

✖ thread-stream async flush should call the passed callback
  Error: /tmp/pino-2965-4196a2c5674889f218dab4af hasn't been created within 10000 ms
  at Timeout.<anonymous> (test/helper.js:75:16)

Two unrelated branches failed the same way in the same window, down to the 11 of 13 job count:

run branch failing test
27361795864 this PR thread-stream async flush should call the passed callback
27915356560 fix/multistream-write-error-isolation same
28184120736 fix/proto-key-serializer-crash same

That test was fixed on main by #2470 (c4d39d4, "test(transport): keep event loop alive while awaiting async flush"), merged July 13, which is after this PR's last run. Main has been green since.

Rebased on current main, the suite matches main. I cherry-picked 9857b9a and b6b0cd6 onto b394c2c and ran the full suite, then ran the same suite on unmodified b394c2c as a control. Windows 11, Node 22.20.0:

tree tests pass fail failing test
main b394c2c 542 537 1 pino.transport with worker destination overridden by bundler and mjs transport
main + this PR 544 539 1 the same one

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). npx eslint . is clean on the rebased tree.

The regression test does measure the change. On the rebased tree, test/transport/protect-from-prototype-pollution.test.js passes 2 of 2. Restoring only lib/tools.js to main and running it again drops it to 1 pass, 1 fail:

not ok 1 - pino() ignores inherited Object.prototype.transport (gadget closed)
  error: 'attacker module was imported via the prototype-polluted transport gadget'

So the assertion is tied to the Object.hasOwn guard and not passing for some unrelated reason.

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.

@mcollina

Copy link
Copy Markdown
Member

The same tests pass on main.

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.

3 participants