Skip to content

Commit f1203e6

Browse files
authored
fix(transport): sanitize invalid NODE_OPTIONS preloads for workers (#2391)
* fix(transport): sanitize invalid NODE_OPTIONS preloads for workers * test(transport): cover NODE_OPTIONS preload sanitization branches
1 parent 6a8e598 commit f1203e6

File tree

4 files changed

+239
-0
lines changed

4 files changed

+239
-0
lines changed

lib/transport.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
'use strict'
22

33
const { createRequire } = require('module')
4+
const { existsSync } = require('node:fs')
45
const getCallers = require('./caller')
56
const { join, isAbsolute, sep } = require('node:path')
7+
const { fileURLToPath } = require('node:url')
68
const sleep = require('atomic-sleep')
79
const onExit = require('on-exit-leak-free')
810
const ThreadStream = require('thread-stream')
@@ -35,6 +37,77 @@ function hasPreloadFlags () {
3537
return false
3638
}
3739

40+
function sanitizeNodeOptions (nodeOptions) {
41+
const tokens = nodeOptions.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)
42+
if (!tokens) {
43+
return nodeOptions
44+
}
45+
46+
const sanitized = []
47+
let changed = false
48+
49+
for (let i = 0; i < tokens.length; i++) {
50+
const token = tokens[i]
51+
52+
if (token === '--require' || token === '-r' || token === '--import') {
53+
const next = tokens[i + 1]
54+
if (next && shouldDropPreload(next)) {
55+
changed = true
56+
i++
57+
continue
58+
}
59+
60+
sanitized.push(token)
61+
if (next) {
62+
sanitized.push(next)
63+
i++
64+
}
65+
continue
66+
}
67+
68+
if (token.startsWith('--require=') || token.startsWith('-r=') || token.startsWith('--import=')) {
69+
const value = token.slice(token.indexOf('=') + 1)
70+
if (shouldDropPreload(value)) {
71+
changed = true
72+
continue
73+
}
74+
}
75+
76+
sanitized.push(token)
77+
}
78+
79+
return changed ? sanitized.join(' ') : nodeOptions
80+
}
81+
82+
function shouldDropPreload (value) {
83+
const unquoted = stripQuotes(value)
84+
if (!unquoted) {
85+
return false
86+
}
87+
88+
let path = unquoted
89+
if (path.startsWith('file://')) {
90+
try {
91+
path = fileURLToPath(path)
92+
} catch {
93+
return false
94+
}
95+
}
96+
97+
return isAbsolute(path) && !existsSync(path)
98+
}
99+
100+
function stripQuotes (value) {
101+
const first = value[0]
102+
const last = value[value.length - 1]
103+
104+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
105+
return value.slice(1, -1)
106+
}
107+
108+
return value
109+
}
110+
38111
function buildStream (filename, workerData, workerOpts, sync, name) {
39112
// When pino is loaded during a preload phase (via --import or --require),
40113
// pass empty execArgv to prevent infinite spawning. Each worker would
@@ -46,6 +119,19 @@ function buildStream (filename, workerData, workerOpts, sync, name) {
46119
}
47120
}
48121

122+
if (!workerOpts.env && process.env.NODE_OPTIONS) {
123+
const nodeOptions = sanitizeNodeOptions(process.env.NODE_OPTIONS)
124+
if (nodeOptions !== process.env.NODE_OPTIONS) {
125+
workerOpts = {
126+
...workerOpts,
127+
env: {
128+
...process.env,
129+
NODE_OPTIONS: nodeOptions
130+
}
131+
}
132+
}
133+
}
134+
49135
workerOpts = { ...workerOpts, name }
50136

51137
const stream = new ThreadStream({
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use strict'
2+
3+
const pino = require('../../')
4+
const { join } = require('node:path')
5+
6+
const destination = process.argv[2]
7+
8+
process.env.NODE_OPTIONS = `--require ${join(__dirname, 'this-file-does-not-exist.js')}`
9+
10+
const transport = pino.transport({
11+
target: join(__dirname, 'to-file-transport.js'),
12+
options: { destination }
13+
})
14+
15+
const logger = pino(transport)
16+
transport.on('ready', () => {
17+
logger.info('hello with invalid node options preload')
18+
setTimeout(() => {
19+
transport.end()
20+
}, 50)
21+
})
22+
23+
transport.on('error', (err) => {
24+
process.stderr.write(`${err.stack}\n`)
25+
process.exitCode = 1
26+
})
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use strict'
2+
3+
const test = require('node:test')
4+
const assert = require('node:assert')
5+
const { EventEmitter } = require('node:events')
6+
const { join } = require('node:path')
7+
const { pathToFileURL } = require('node:url')
8+
const proxyquire = require('proxyquire')
9+
10+
function buildTransportWithFakeThreadStream () {
11+
let lastCtorOpts
12+
13+
class FakeThreadStream extends EventEmitter {
14+
constructor (opts) {
15+
super()
16+
this._closed = false
17+
lastCtorOpts = opts
18+
}
19+
20+
unref () {}
21+
ref () {}
22+
flushSync () {}
23+
end () {
24+
this._closed = true
25+
this.emit('close')
26+
}
27+
28+
get closed () {
29+
return this._closed
30+
}
31+
}
32+
33+
const transport = proxyquire('../../lib/transport', {
34+
'thread-stream': FakeThreadStream
35+
})
36+
37+
return {
38+
transport,
39+
getLastCtorOpts () {
40+
return lastCtorOpts
41+
}
42+
}
43+
}
44+
45+
test('pino.transport sanitizes missing absolute preload in NODE_OPTIONS', () => {
46+
const previous = process.env.NODE_OPTIONS
47+
const missing = join(__dirname, '..', 'fixtures', 'missing-preload.js')
48+
process.env.NODE_OPTIONS = `--require ${missing} --trace-warnings`
49+
50+
const { transport, getLastCtorOpts } = buildTransportWithFakeThreadStream()
51+
transport({ target: join(__dirname, '..', 'fixtures', 'to-file-transport.js') })
52+
53+
assert.equal(getLastCtorOpts().workerOpts.env.NODE_OPTIONS, '--trace-warnings')
54+
55+
if (previous === undefined) {
56+
delete process.env.NODE_OPTIONS
57+
} else {
58+
process.env.NODE_OPTIONS = previous
59+
}
60+
})
61+
62+
test('pino.transport sanitizes missing file:// preload in NODE_OPTIONS', () => {
63+
const previous = process.env.NODE_OPTIONS
64+
const missingFileUrl = pathToFileURL(join(__dirname, '..', 'fixtures', 'missing-import.mjs')).href
65+
process.env.NODE_OPTIONS = `--import=${missingFileUrl}`
66+
67+
const { transport, getLastCtorOpts } = buildTransportWithFakeThreadStream()
68+
transport({ target: join(__dirname, '..', 'fixtures', 'to-file-transport.js') })
69+
70+
assert.equal(getLastCtorOpts().workerOpts.env.NODE_OPTIONS, '')
71+
72+
if (previous === undefined) {
73+
delete process.env.NODE_OPTIONS
74+
} else {
75+
process.env.NODE_OPTIONS = previous
76+
}
77+
})
78+
79+
test('pino.transport keeps relative preload flags in NODE_OPTIONS', () => {
80+
const previous = process.env.NODE_OPTIONS
81+
process.env.NODE_OPTIONS = '--require ./relative-preload.js'
82+
83+
const { transport, getLastCtorOpts } = buildTransportWithFakeThreadStream()
84+
transport({ target: join(__dirname, '..', 'fixtures', 'to-file-transport.js') })
85+
86+
assert.equal(getLastCtorOpts().workerOpts.env, undefined)
87+
88+
if (previous === undefined) {
89+
delete process.env.NODE_OPTIONS
90+
} else {
91+
process.env.NODE_OPTIONS = previous
92+
}
93+
})
94+
95+
test('pino.transport does not override explicit worker.env', () => {
96+
const previous = process.env.NODE_OPTIONS
97+
process.env.NODE_OPTIONS = `--require ${join(__dirname, '..', 'fixtures', 'missing-preload.js')}`
98+
99+
const explicitEnv = { NODE_OPTIONS: '--trace-warnings' }
100+
101+
const { transport, getLastCtorOpts } = buildTransportWithFakeThreadStream()
102+
transport({
103+
target: join(__dirname, '..', 'fixtures', 'to-file-transport.js'),
104+
worker: {
105+
env: explicitEnv
106+
}
107+
})
108+
109+
assert.equal(getLastCtorOpts().workerOpts.env, explicitEnv)
110+
111+
if (previous === undefined) {
112+
delete process.env.NODE_OPTIONS
113+
} else {
114+
process.env.NODE_OPTIONS = previous
115+
}
116+
})

test/transport/preload.test.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,14 @@ test('pino.transport works when loaded via --import preload (space separated)',
4141
const result = JSON.parse(await readFile(destination))
4242
assert.equal(result.msg, 'hello from main')
4343
})
44+
45+
test('pino.transport ignores missing absolute preload from NODE_OPTIONS in worker', async () => {
46+
const destination = file()
47+
const main = join(__dirname, '..', 'fixtures', 'transport-invalid-node-options.js')
48+
49+
await execa(process.argv[0], [main, destination], { timeout: 10000 })
50+
51+
await watchFileCreated(destination)
52+
const result = JSON.parse(await readFile(destination))
53+
assert.equal(result.msg, 'hello with invalid node options preload')
54+
})

0 commit comments

Comments
 (0)