Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 157 additions & 1 deletion packages/shell-bson-parser/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { expect } from 'chai';
import sinon from 'sinon';
import vm from 'vm';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import * as WebWorkerModule from 'web-worker';

import * as api from './index.js';
import { terminateWorker } from './worker-client.js';
import {
restrictGlobalScope,
restrictObjectPrototype,
ALLOWED_GLOBALS,
DISALLOWED_PROTOTYPE_PROPS,
} from './worker.js';
import { PARSE_TEST_CASES } from './../test/parse-test-cases.js';

const WebWorker = (WebWorkerModule as unknown as { default: typeof Worker })
.default;

describe('shell-bson-parser with webworker processing', function () {
const initialWorkerScriptUrl = process.env.TEST_WORKER_SCRIPT_URL;

Expand Down Expand Up @@ -39,15 +54,156 @@ describe('shell-bson-parser with webworker processing', function () {
}
});

describe('restrictGlobalScope', function () {
it('strips capabilities not needed for parsing, keeping the JS intrinsics parsing needs', function () {
const scope: Record<string, unknown> = Object.create(null);
scope.fetch = function fetch() {};
scope.require = function require() {};
scope.process = Object.create(null);
scope.importScripts = function importScripts() {};
scope.XMLHttpRequest = function XMLHttpRequest() {};
scope.Object = Object;
scope.Array = Array;
scope.Math = Math;

restrictGlobalScope(scope);

expect(scope).to.not.have.property('fetch');
expect(scope).to.not.have.property('require');
expect(scope).to.not.have.property('importScripts');
expect(scope).to.not.have.property('XMLHttpRequest');
expect(scope).to.have.property('process');
expect(scope).to.have.property('Object', Object);
expect(scope).to.have.property('Array', Array);
expect(scope).to.have.property('Math', Math);
});

it('keeps every allowlisted global untouched', function () {
const scope: Record<string, unknown> = Object.create(null);
for (const key of ALLOWED_GLOBALS) {
scope[key] = key;
}

restrictGlobalScope(scope);

for (const key of ALLOWED_GLOBALS) {
expect(scope).to.have.property(key, key);
}
});

it('does not throw on non-configurable properties', function () {
const scope: Record<string, unknown> = Object.create(null);
Object.defineProperty(scope, 'nonConfigurable', {
value: 'danger',
configurable: false,
enumerable: true,
});

expect(() => restrictGlobalScope(scope)).to.not.throw();
expect(scope).to.have.property('nonConfigurable', 'danger');
});
});

describe('lockdownObjectPrototype', function () {
let originalDescriptors: Record<string, PropertyDescriptor | undefined>;
beforeEach(function () {
originalDescriptors = Object.create(null);
for (const key of DISALLOWED_PROTOTYPE_PROPS) {
originalDescriptors[key] = Object.getOwnPropertyDescriptor(
Object.prototype,
key,
);
}
});

afterEach(function () {
for (const key of DISALLOWED_PROTOTYPE_PROPS) {
const descriptor = originalDescriptors[key];
if (descriptor) {
Object.defineProperty(Object.prototype, key, descriptor);
}
}
});

it('removes every disallowed accessor from Object.prototype', function () {
restrictObjectPrototype();

for (const key of DISALLOWED_PROTOTYPE_PROPS) {
expect(Object.prototype).to.not.have.property(key);
expect(({} as Record<string, unknown>)[key]).to.equal(undefined);
}
});
});

describe('terminateWorker', function () {
it('starts a new worker after termination', async function () {
let terminateSpy: sinon.SinonSpy;

beforeEach(function () {
terminateSpy = sinon.spy(WebWorker.prototype, 'terminate');
});

afterEach(function () {
terminateSpy.restore();
terminateWorker();
});

it('actually calls terminate() on the underlying worker, then spins up a new one', async function () {
const res1 = await api.parse('{code: "BER"}');
expect(res1).to.deep.equal({ code: 'BER' });
expect(terminateSpy.called).to.equal(false);

terminateWorker();
expect(terminateSpy.calledOnce).to.equal(true);

const res2 = await api.parse('{city: "berlin"}');
expect(res2).to.deep.equal({ city: 'berlin' });

expect(terminateSpy.calledOnce).to.equal(true);
});
});

it('strips dangerous globals and locks down Object.prototype when the real worker starts', async function () {
const workerBundlePath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'dist',
'worker.js',
);
const code = await fs.readFile(workerBundlePath, 'utf8');

const sandbox: Record<string, unknown> = Object.create(null);
sandbox.postMessage = function postMessage() {};
sandbox.fetch = function fetch() {};
sandbox.require = function require() {};
sandbox.importScripts = function importScripts() {};
sandbox.XMLHttpRequest = function XMLHttpRequest() {};
sandbox.self = sandbox;
sandbox.global = sandbox;
sandbox.globalThis = sandbox;

vm.createContext(sandbox);

expect(sandbox).to.have.property('fetch');
expect(sandbox).to.have.property('require');
expect(sandbox).to.have.property('importScripts');
expect(sandbox).to.have.property('XMLHttpRequest');

vm.runInContext(code, sandbox, { filename: 'worker.js' });

expect(sandbox).to.not.have.property('fetch');
expect(sandbox).to.not.have.property('require');
expect(sandbox).to.not.have.property('importScripts');
expect(sandbox).to.not.have.property('XMLHttpRequest');

expect(typeof sandbox.onmessage).to.equal('function');

const stillHasProtoAccessor = vm.runInContext(
`Object.prototype.hasOwnProperty('__proto__')`,
sandbox,
);
expect(stillHasProtoAccessor).to.equal(false);

// It should not modify the default object proto
expect(Object.prototype).to.have.property('__proto__');
});
});
11 changes: 5 additions & 6 deletions packages/shell-bson-parser/src/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const SCOPE_ANY: { [x: string]: Function } = lookupMap({
String.prototype.substring.call(lsb, 0, 2);
hex = msb + lsb;

const hexBuffer = Buffer.from(hex, 'hex');
const hexBuffer = bson.ByteUtils.fromHex(hex);
return new bson.Binary(hexBuffer, 3);
},
LegacyCSharpUUID: function (u: any) {
Expand All @@ -97,7 +97,7 @@ const SCOPE_ANY: { [x: string]: Function } = lookupMap({
const d = String.prototype.substring.call(hex, 16, 32);
hex = a + b + c + d;

const hexBuffer = Buffer.from(hex, 'hex');
const hexBuffer = bson.ByteUtils.fromHex(hex);
return new bson.Binary(hexBuffer, 3);
},
LegacyPythonUUID: function (u: any) {
Expand All @@ -106,21 +106,20 @@ const SCOPE_ANY: { [x: string]: Function } = lookupMap({
}

return new bson.Binary(
Buffer.from(
bson.ByteUtils.fromHex(
String.prototype.replace.call(u, /[{}-]/g, () => ''),
'hex',
),
3,
);
},
BinData: function (t: any, d: any) {
return new bson.Binary(Buffer.from(d, 'base64'), t);
return new bson.Binary(bson.ByteUtils.fromBase64(d), t);
},
UUID: function (u: any) {
if (u === undefined) {
return new bson.UUID().toBinary();
}
return new bson.Binary(Buffer.from(u.replace(/-/g, ''), 'hex'), 4);
return new bson.Binary(bson.ByteUtils.fromHex(u.replace(/-/g, '')), 4);
},
Code: function (c: any, s: any) {
return new bson.Code(c, s);
Expand Down
105 changes: 103 additions & 2 deletions packages/shell-bson-parser/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,106 @@ import { trackBSON, untrackBSON } from './structured-clone-bson.js';

import type { WorkerRequest, WorkerResponse } from './worker-types.js';

// Exported for test
const { self, postMessage } = globalThis;

// Exported for tests
export const ALLOWED_GLOBALS = new Set([
'Object',
'Array',
'Function',
'String',
'Number',
'Boolean',
'Symbol',
'BigInt',
'Math',
'Date',
'RegExp',
'JSON',
'Map',
'Set',
'WeakMap',
'WeakSet',

@LuciaHarcekova LuciaHarcekova Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Based on what data is the full allow list constructed?

I could see "console" isn't in allow list. Could be the case web-worker's Node shim calls console.error when a handler throws, which would turn errors into confusing ReferenceErrors.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This list is based on the current tests, integration with compass, globals needed for parsing etc.
Nice catch with console. I'll add that to this list

'Promise',
'Proxy',
'Reflect',
'Error',
'TypeError',
'RangeError',
'SyntaxError',
'ReferenceError',
'EvalError',
'URIError',
'ArrayBuffer',
'SharedArrayBuffer',
'DataView',
'Uint8Array',
'Int8Array',
'Uint8ClampedArray',
'Uint16Array',
'Int16Array',
'Uint32Array',
'Int32Array',
'Float32Array',
'Float64Array',
'BigInt64Array',
'BigUint64Array',
'TextEncoder',
'TextDecoder',
'undefined',
'NaN',
'Infinity',
'isNaN',
'isFinite',
'parseFloat',
'parseInt',
'encodeURIComponent',
'decodeURIComponent',
'Buffer',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Buffer is a Node.js concept, do we need that?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It was added to support the usage around LegacyUUID in scope.ts. I switched to bson.ByteUtils instead in 4623dfa.

Also, now that we added support for node as well, I've to keep it as web-worker package relies on it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hi @mabaasit, the ticket says to remove Node globals, but the PR’s allowlist still includes both process and Buffer. The comment says process is needed by the web-worker package. You mentioned the code switched to bson.ByteUtils instead of Buffer (I can't open 4623dfa link - if it's addressed there). Confirming whether Buffer is still needed and whether keeping process is an acceptable exception to the task’s security goal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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


// bson's byteutils use these internally
'atob',
'btoa',
'crypto',

// web-worker module relies on process as it supports both node and browser.
'process',
'console',
]);

// Exported for tests
export function restrictGlobalScope(scope: object): void {
for (const key of Object.getOwnPropertyNames(scope)) {
if (ALLOWED_GLOBALS.has(key)) continue;
try {
delete (scope as any)[key];
} catch {
// Non-configurable in this environment
}
}
}

// Exported for tests
export const DISALLOWED_PROTOTYPE_PROPS = [
'__proto__',
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__',
] as const;

// Exported for tests
export function restrictObjectPrototype(): void {
for (const key of DISALLOWED_PROTOTYPE_PROPS) {
try {
delete (Object.prototype as any)[key];
} catch {
// Non-configurable in this environment
}
}
}

// Exported for tests
export function handleRequest(request: WorkerRequest): WorkerResponse {
const { id, args } = request;
try {
Expand All @@ -16,7 +115,9 @@ export function handleRequest(request: WorkerRequest): WorkerResponse {
}

if (typeof self !== 'undefined') {
restrictObjectPrototype();
restrictGlobalScope(globalThis);
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
(self as unknown as Worker).postMessage(handleRequest(event.data));
postMessage(handleRequest(event.data));
};
}
Loading