Skip to content

Commit bebbda2

Browse files
authored
Merge commit from fork
* security: harden Piscina options against prototype pollution Harden Piscina against prototype pollution by ensuring all user-controlled option reads come from own properties and by storing pool options on a null-prototype object. This prevents attackers who can pollute Object.prototype from influencing worker configuration (filename, name, transferList, signal, force) or pool defaults. NB: pool.options is now a null-prototype object. This is a potential breaking change for consumers that call Object.prototype methods directly on pool.options (e.g. pool.options.hasOwnProperty(...)). * fix: use sanitized options for resourceLimits validation * fix: sanitize run/close options with withNullPrototype Use the existing withNullPrototype helper instead of getOwn; address feedback from @metcoder95 * fix: avoid re-linking Object.prototype in Piscina constructor
1 parent a20ff72 commit bebbda2

3 files changed

Lines changed: 81 additions & 11 deletions

File tree

src/common.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,9 @@ export function maybeFileURLToPath (filename : string) : string {
5959
export function getAvailableParallelism () : number {
6060
return availableParallelism();
6161
}
62+
63+
// Copy own properties onto a prototype-less object, so that options are never
64+
// resolved through a polluted prototype chain.
65+
export function withNullPrototype<T extends object>(source: T, overrides?: Partial<T>): T {
66+
return Object.assign(Object.create(null), source, overrides)
67+
}

src/index.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
markMovable,
4949
getAvailableParallelism,
5050
maybeFileURLToPath,
51+
withNullPrototype,
5152
} from './common';
5253
const cpuParallelism : number = getAvailableParallelism();
5354

@@ -185,7 +186,12 @@ class ThreadPool {
185186

186187
const filename =
187188
options.filename ? maybeFileURLToPath(options.filename) : null;
188-
this.options = { ...kDefaultOptions, ...options, filename, maxQueue: 0 };
189+
this.options = withNullPrototype({
190+
...kDefaultOptions,
191+
...options,
192+
filename,
193+
maxQueue: 0,
194+
});
189195

190196
if (this.options.recordTiming) {
191197
this.histogram = new PiscinaHistogramHandler();
@@ -741,8 +747,8 @@ export default class Piscina<Exports extends Record<string, (payload: any) => an
741747
#histogram: PiscinaHistogram | null = null;
742748

743749
constructor (options : Options = {}) {
744-
const opts = { ...options, '__proto__': null };
745-
super({ ...opts, name: 'Piscina' });
750+
const opts = withNullPrototype(options);
751+
super(withNullPrototype(opts, { name: 'Piscina' }));
746752

747753
if (typeof opts.filename !== 'string' && opts.filename != null) {
748754
throw Errors.ValidationError('options.filename must be a string or null');
@@ -781,7 +787,7 @@ export default class Piscina<Exports extends Record<string, (payload: any) => an
781787
!['sync', 'async', 'disabled'].includes(opts.atomics))) {
782788
throw Errors.ValidationError('options.atomics should be a value of sync, sync or disabled.');
783789
}
784-
if (options.resourceLimits != null && typeof options.resourceLimits !== 'object') {
790+
if (opts.resourceLimits != null && typeof opts.resourceLimits !== 'object') {
785791
throw Errors.ValidationError('options.resourceLimits must be an object');
786792
}
787793
if (opts.taskQueue != null && !isTaskQueue(opts.taskQueue)) {
@@ -817,12 +823,12 @@ export default class Piscina<Exports extends Record<string, (payload: any) => an
817823
Errors.ValidationError('options must be an object'));
818824
}
819825

820-
const {
821-
transferList,
822-
signal
823-
} = options;
824-
const filename = Object.prototype.hasOwnProperty.call(options, 'filename') ? options.filename : null;
825-
const name = Object.prototype.hasOwnProperty.call(options, 'name') ? options.name : null;
826+
options = withNullPrototype(options);
827+
828+
const transferList = options.transferList;
829+
const signal = options.signal ?? null;
830+
const filename = options.filename ?? null;
831+
const name = options.name ?? null;
826832

827833
if (transferList !== undefined && !Array.isArray(transferList)) {
828834
return Promise.reject(
@@ -848,6 +854,8 @@ export default class Piscina<Exports extends Record<string, (payload: any) => an
848854
throw Errors.ValidationError('options must be an object');
849855
}
850856

857+
options = withNullPrototype(options);
858+
851859
let { force } = options;
852860

853861
if (force != null && typeof force !== 'boolean') {

test/option-validation.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,60 @@ test('trackUnmanagedFds must be a boolean', () => {
149149
assert.throws(() => new Piscina(({
150150
trackUnmanagedFds: 'string'
151151
}) as any), /options.trackUnmanagedFds must be a boolean/);
152-
});
152+
});
153+
154+
test('execArgv is not tampered', async () => {
155+
(Object.prototype as any).execArgv = ['--not-a-real-flag']
156+
157+
const pool = new Piscina({
158+
filename: resolve(__dirname, 'fixtures/eval.js'),
159+
minThreads: 1,
160+
maxThreads: 1
161+
})
162+
163+
try {
164+
assert.strictEqual(pool.options.execArgv, undefined)
165+
assert.strictEqual(await pool.run('42'), 42)
166+
} finally {
167+
delete (Object.prototype as any).execArgv
168+
await pool.close()
169+
}
170+
})
171+
172+
test('loadBalancer is not tampered', async () => {
173+
let called = false
174+
;(Object.prototype as any).loadBalancer = () => { called = true; return null }
175+
176+
const pool = new Piscina({
177+
filename: resolve(__dirname, 'fixtures/eval.js'),
178+
minThreads: 1,
179+
maxThreads: 1
180+
})
181+
182+
try {
183+
assert.strictEqual(pool.options.loadBalancer, undefined)
184+
assert.strictEqual(await pool.run('42'), 42)
185+
assert.strictEqual(called, false)
186+
} finally {
187+
delete (Object.prototype as any).loadBalancer
188+
await pool.close()
189+
}
190+
})
191+
192+
test('env is not tampered', async () => {
193+
(Object.prototype as any).env = { NODE_OPTIONS: '--title=polluted' }
194+
195+
const pool = new Piscina({
196+
filename: resolve(__dirname, 'fixtures/eval.js'),
197+
minThreads: 1,
198+
maxThreads: 1
199+
})
200+
201+
try {
202+
assert.strictEqual(pool.options.env, undefined)
203+
assert.strictEqual(await pool.run('42'), 42)
204+
} finally {
205+
delete (Object.prototype as any).env
206+
await pool.close()
207+
}
208+
})

0 commit comments

Comments
 (0)