From f1f3220d1eb62203625c7dc348144a57d38b30f4 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Tue, 18 Jul 2023 01:50:42 -0700 Subject: [PATCH] Use `quickjs-emscripten` instead of `vm2` to execute PAC file code (#224) The `vm2` module has been deprecated and has critical security vulnerabilities. The suggested replacement module `isolated-vm` is not suitable for these packages, since it relies on a C++ binary. Instead, these packages will use the `quickjs-emscripten` module to execute the user code in an isolated QuickJS environment compiled to WASM. This should allow the highest level of sandboxing and will hopefully put an end to this cat and mouse game once and for all. Fixes #218. --- .changeset/chatty-roses-wink.md | 7 + .changeset/new-goats-arrive.md | 5 + packages/degenerator/package.json | 4 +- packages/degenerator/src/compile.ts | 135 +++++++++++++++ packages/degenerator/src/degenerator.ts | 176 +++++++++++++++++++ packages/degenerator/src/index.ts | 217 +----------------------- packages/degenerator/test/test.ts | 83 +++++---- packages/pac-proxy-agent/package.json | 1 + packages/pac-proxy-agent/src/index.ts | 8 +- packages/pac-resolver/README.md | 4 +- packages/pac-resolver/package.json | 1 + packages/pac-resolver/src/index.ts | 22 +-- packages/pac-resolver/test/test.ts | 182 ++++++++++++-------- pnpm-lock.yaml | 35 ++-- 14 files changed, 509 insertions(+), 371 deletions(-) create mode 100644 .changeset/chatty-roses-wink.md create mode 100644 .changeset/new-goats-arrive.md create mode 100644 packages/degenerator/src/compile.ts create mode 100644 packages/degenerator/src/degenerator.ts diff --git a/.changeset/chatty-roses-wink.md b/.changeset/chatty-roses-wink.md new file mode 100644 index 00000000..ebbcaec3 --- /dev/null +++ b/.changeset/chatty-roses-wink.md @@ -0,0 +1,7 @@ +--- +'pac-proxy-agent': major +'pac-resolver': major +'degenerator': major +--- + +Use `quickjs-emscripten` instead of `vm2` to execute PAC file code diff --git a/.changeset/new-goats-arrive.md b/.changeset/new-goats-arrive.md new file mode 100644 index 00000000..c9c51333 --- /dev/null +++ b/.changeset/new-goats-arrive.md @@ -0,0 +1,5 @@ +--- +'proxy-agent': minor +--- + +Use QuickJS version of `pac-proxy-agent` diff --git a/packages/degenerator/package.json b/packages/degenerator/package.json index 14e07514..fa5a8112 100644 --- a/packages/degenerator/package.json +++ b/packages/degenerator/package.json @@ -26,10 +26,10 @@ "dependencies": { "ast-types": "^0.13.4", "escodegen": "^1.14.3", - "esprima": "^4.0.1", - "vm2": "^3.9.19" + "esprima": "^4.0.1" }, "devDependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", "@types/escodegen": "^0.0.6", "@types/esprima": "^4.0.3", "@types/jest": "^29.5.2", diff --git a/packages/degenerator/src/compile.ts b/packages/degenerator/src/compile.ts new file mode 100644 index 00000000..418eb055 --- /dev/null +++ b/packages/degenerator/src/compile.ts @@ -0,0 +1,135 @@ +import { types } from 'util'; +import { degenerator } from './degenerator'; +import type { Context } from 'vm'; +import type { + QuickJSContext, + QuickJSHandle, + QuickJSWASMModule, +} from '@tootallnate/quickjs-emscripten'; +import type { DegeneratorNames } from './degenerator'; + +export interface CompileOptions { + names?: DegeneratorNames; + filename?: string; + sandbox?: Context; +} + +export function compile( + qjs: QuickJSWASMModule, + code: string, + returnName: string, + options: CompileOptions = {} +): (...args: A) => Promise { + const compiled = degenerator(code, options.names ?? []); + + const vm = qjs.newContext(); + + // Add functions to global + if (options.sandbox) { + for (const [name, value] of Object.entries(options.sandbox)) { + if (typeof value !== 'function') { + throw new Error( + `Expected a "function" for sandbox property \`${name}\`, but got "${typeof value}"` + ); + } + const fnHandle = vm.newFunction(name, (...args) => { + //console.log('invoke', { name, args }); + const result = value( + ...args.map((arg) => quickJSHandleToHost(vm, arg)) + ); + vm.runtime.executePendingJobs(); + return hostToQuickJSHandle(vm, result); + }); + fnHandle.consume((handle) => vm.setProp(vm.global, name, handle)); + } + } + + //console.log(compiled); + const fnResult = vm.evalCode(`${compiled};${returnName}`, options.filename); + const fn = vm.unwrapResult(fnResult); + + const t = vm.typeof(fn); + if (t !== 'function') { + throw new Error( + `Expected a "function" named \`${returnName}\` to be defined, but got "${t}"` + ); + } + const r = async function (...args: A): Promise { + let promiseHandle: QuickJSHandle | undefined; + let resolvedHandle: QuickJSHandle | undefined; + try { + const result = vm.callFunction( + fn, + vm.undefined, + ...args.map((arg) => hostToQuickJSHandle(vm, arg)) + ); + promiseHandle = vm.unwrapResult(result); + const resolvedResultP = vm.resolvePromise(promiseHandle); + vm.runtime.executePendingJobs(); + const resolvedResult = await resolvedResultP; + resolvedHandle = vm.unwrapResult(resolvedResult); + return quickJSHandleToHost(vm, resolvedHandle); + } catch (err: unknown) { + if (err && typeof err === 'object' && 'cause' in err && err.cause) { + if ( + typeof err.cause === 'object' && + 'stack' in err.cause && + 'name' in err.cause && + 'message' in err.cause && + typeof err.cause.stack === 'string' && + typeof err.cause.name === 'string' && + typeof err.cause.message === 'string' + ) { + // QuickJS Error `stack` does not include the name + + // message, so patch those in to behave more like V8 + err.cause.stack = `${err.cause.name}: ${err.cause.message}\n${err.cause.stack}`; + } + throw err.cause; + } + throw err; + } finally { + promiseHandle?.dispose(); + resolvedHandle?.dispose(); + } + }; + Object.defineProperty(r, 'toString', { + value: () => compiled, + enumerable: false, + }); + return r; +} + +function quickJSHandleToHost(vm: QuickJSContext, val: QuickJSHandle) { + return vm.dump(val); +} + +function hostToQuickJSHandle(vm: QuickJSContext, val: unknown): QuickJSHandle { + if (typeof val === 'undefined') { + return vm.undefined; + } else if (val === null) { + return vm.null; + } else if (typeof val === 'string') { + return vm.newString(val); + } else if (typeof val === 'number') { + return vm.newNumber(val); + } else if (typeof val === 'bigint') { + return vm.newBigInt(val); + } else if (typeof val === 'boolean') { + return val ? vm.true : vm.false; + } else if (types.isPromise(val)) { + const promise = vm.newPromise(); + promise.settled.then(vm.runtime.executePendingJobs); + val.then( + (r: unknown) => { + promise.resolve(hostToQuickJSHandle(vm, r)); + }, + (err: unknown) => { + promise.reject(hostToQuickJSHandle(vm, err)); + } + ); + return promise.handle; + } else if (types.isNativeError(val)) { + return vm.newError(val); + } + throw new Error(`Unsupported value: ${val}`); +} diff --git a/packages/degenerator/src/degenerator.ts b/packages/degenerator/src/degenerator.ts new file mode 100644 index 00000000..463cba00 --- /dev/null +++ b/packages/degenerator/src/degenerator.ts @@ -0,0 +1,176 @@ +import { types } from 'util'; +import { generate } from 'escodegen'; +import { parseScript } from 'esprima'; +import { visit, namedTypes as n, builders as b } from 'ast-types'; + +export type DegeneratorName = string | RegExp; +export type DegeneratorNames = DegeneratorName[]; + +/** + * Compiles sync JavaScript code into JavaScript with async Functions. + * + * @param {String} code JavaScript string to convert + * @param {Array} names Array of function names to add `await` operators to + * @return {String} Converted JavaScript string with async/await injected + * @api public + */ + +export function degenerator(code: string, _names: DegeneratorNames): string { + if (!Array.isArray(_names)) { + throw new TypeError('an array of async function "names" is required'); + } + + // Duplicate the `names` array since it's rude to augment the user args + const names = _names.slice(0); + + const ast = parseScript(code); + + // First pass is to find the `function` nodes and turn them into async or + // generator functions only if their body includes `CallExpressions` to + // function in `names`. We also add the names of the functions to the `names` + // array. We'll iterate several time, as every iteration might add new items + // to the `names` array, until no new names were added in the iteration. + let lastNamesLength = 0; + do { + lastNamesLength = names.length; + visit(ast, { + visitVariableDeclaration(path) { + if (path.node.declarations) { + for (let i = 0; i < path.node.declarations.length; i++) { + const declaration = path.node.declarations[i]; + if ( + n.VariableDeclarator.check(declaration) && + n.Identifier.check(declaration.init) && + n.Identifier.check(declaration.id) && + checkName(declaration.init.name, names) && + !checkName(declaration.id.name, names) + ) { + names.push(declaration.id.name); + } + } + } + return false; + }, + visitAssignmentExpression(path) { + if ( + n.Identifier.check(path.node.left) && + n.Identifier.check(path.node.right) && + checkName(path.node.right.name, names) && + !checkName(path.node.left.name, names) + ) { + names.push(path.node.left.name); + } + return false; + }, + visitFunction(path) { + if (path.node.id) { + let shouldDegenerate = false; + visit(path.node, { + visitCallExpression(path) { + if (checkNames(path.node, names)) { + shouldDegenerate = true; + } + return false; + }, + }); + + if (!shouldDegenerate) { + return false; + } + + // Got a "function" expression/statement, + // convert it into an async function + path.node.async = true; + + // Add function name to `names` array + if (!checkName(path.node.id.name, names)) { + names.push(path.node.id.name); + } + } + + this.traverse(path); + }, + }); + } while (lastNamesLength !== names.length); + + // Second pass is for adding `await` statements to any function + // invocations that match the given `names` array. + visit(ast, { + visitCallExpression(path) { + if (checkNames(path.node, names)) { + // A "function invocation" expression, + // we need to inject an `AwaitExpression` + const delegate = false; + const { + name, + parent: { node: pNode }, + } = path; + + const expr = b.awaitExpression(path.node, delegate); + + if (n.CallExpression.check(pNode)) { + pNode.arguments[name] = expr; + } else { + pNode[name] = expr; + } + } + + this.traverse(path); + }, + }); + + return generate(ast); +} + +/** + * Returns `true` if `node` has a matching name to one of the entries in the + * `names` array. + * + * @param {types.Node} node + * @param {Array} names Array of function names to return true for + * @return {Boolean} + * @api private + */ + +function checkNames( + { callee }: n.CallExpression, + names: DegeneratorNames +): boolean { + let name: string; + if (n.Identifier.check(callee)) { + name = callee.name; + } else if (n.MemberExpression.check(callee)) { + if ( + n.Identifier.check(callee.object) && + n.Identifier.check(callee.property) + ) { + name = `${callee.object.name}.${callee.property.name}`; + } else { + return false; + } + } else if (n.FunctionExpression.check(callee)) { + if (callee.id) { + name = callee.id.name; + } else { + return false; + } + } else { + throw new Error(`Don't know how to get name for: ${callee.type}`); + } + return checkName(name, names); +} + +function checkName(name: string, names: DegeneratorNames): boolean { + // now that we have the `name`, check if any entries match in the `names` array + for (let i = 0; i < names.length; i++) { + const n = names[i]; + if (types.isRegExp(n)) { + if (n.test(name)) { + return true; + } + } else if (name === n) { + return true; + } + } + return false; +} diff --git a/packages/degenerator/src/index.ts b/packages/degenerator/src/index.ts index 45835f0f..2dc06cd3 100644 --- a/packages/degenerator/src/index.ts +++ b/packages/degenerator/src/index.ts @@ -1,215 +1,2 @@ -import { types } from 'util'; -import { generate } from 'escodegen'; -import { parseScript } from 'esprima'; -import { visit, namedTypes as n, builders as b } from 'ast-types'; -import { Context, RunningScriptOptions } from 'vm'; -import { VM, VMScript } from 'vm2'; - -/** - * Compiles sync JavaScript code into JavaScript with async Functions. - * - * @param {String} code JavaScript string to convert - * @param {Array} names Array of function names to add `await` operators to - * @return {String} Converted JavaScript string with async/await injected - * @api public - */ - -export function degenerator(code: string, _names: DegeneratorNames): string { - if (!Array.isArray(_names)) { - throw new TypeError('an array of async function "names" is required'); - } - - // Duplicate the `names` array since it's rude to augment the user args - const names = _names.slice(0); - - const ast = parseScript(code); - - // First pass is to find the `function` nodes and turn them into async or - // generator functions only if their body includes `CallExpressions` to - // function in `names`. We also add the names of the functions to the `names` - // array. We'll iterate several time, as every iteration might add new items - // to the `names` array, until no new names were added in the iteration. - let lastNamesLength = 0; - do { - lastNamesLength = names.length; - visit(ast, { - visitVariableDeclaration(path) { - if (path.node.declarations) { - for (let i = 0; i < path.node.declarations.length; i++) { - const declaration = path.node.declarations[i]; - if ( - n.VariableDeclarator.check(declaration) && - n.Identifier.check(declaration.init) && - n.Identifier.check(declaration.id) && - checkName(declaration.init.name, names) && - !checkName(declaration.id.name, names) - ) { - names.push(declaration.id.name); - } - } - } - return false; - }, - visitAssignmentExpression(path) { - if ( - n.Identifier.check(path.node.left) && - n.Identifier.check(path.node.right) && - checkName(path.node.right.name, names) && - !checkName(path.node.left.name, names) - ) { - names.push(path.node.left.name); - } - return false; - }, - visitFunction(path) { - if (path.node.id) { - let shouldDegenerate = false; - visit(path.node, { - visitCallExpression(path) { - if (checkNames(path.node, names)) { - shouldDegenerate = true; - } - return false; - }, - }); - - if (!shouldDegenerate) { - return false; - } - - // Got a "function" expression/statement, - // convert it into an async function - path.node.async = true; - - // Add function name to `names` array - if (!checkName(path.node.id.name, names)) { - names.push(path.node.id.name); - } - } - - this.traverse(path); - }, - }); - } while (lastNamesLength !== names.length); - - // Second pass is for adding `await` statements to any function - // invocations that match the given `names` array. - visit(ast, { - visitCallExpression(path) { - if (checkNames(path.node, names)) { - // A "function invocation" expression, - // we need to inject an `AwaitExpression` - const delegate = false; - const { - name, - parent: { node: pNode }, - } = path; - - const expr = b.awaitExpression(path.node, delegate); - - if (n.CallExpression.check(pNode)) { - pNode.arguments[name] = expr; - } else { - pNode[name] = expr; - } - } - - this.traverse(path); - }, - }); - - return generate(ast); -} - -export type DegeneratorName = string | RegExp; -export type DegeneratorNames = DegeneratorName[]; -export interface CompileOptions extends RunningScriptOptions { - sandbox?: Context; -} -export function compile( - code: string, - returnName: string, - names: DegeneratorNames, - options: CompileOptions = {} -): (...args: A) => Promise { - const compiled = degenerator(code, names); - const vm = new VM(options); - const script = new VMScript(`${compiled};${returnName}`, { - filename: options.filename, - }); - const fn = vm.run(script); - if (typeof fn !== 'function') { - throw new Error( - `Expected a "function" to be returned for \`${returnName}\`, but got "${typeof fn}"` - ); - } - const r = function (this: unknown, ...args: A): Promise { - try { - const p = fn.apply(this, args); - if (typeof p?.then === 'function') { - return p; - } - return Promise.resolve(p); - } catch (err) { - return Promise.reject(err); - } - }; - Object.defineProperty(r, 'toString', { - value: fn.toString.bind(fn), - enumerable: false, - }); - return r; -} - -/** - * Returns `true` if `node` has a matching name to one of the entries in the - * `names` array. - * - * @param {types.Node} node - * @param {Array} names Array of function names to return true for - * @return {Boolean} - * @api private - */ - -function checkNames( - { callee }: n.CallExpression, - names: DegeneratorNames -): boolean { - let name: string; - if (n.Identifier.check(callee)) { - name = callee.name; - } else if (n.MemberExpression.check(callee)) { - if ( - n.Identifier.check(callee.object) && - n.Identifier.check(callee.property) - ) { - name = `${callee.object.name}.${callee.property.name}`; - } else { - return false; - } - } else if (n.FunctionExpression.check(callee)) { - if (callee.id) { - name = callee.id.name; - } else { - return false; - } - } else { - throw new Error(`Don't know how to get name for: ${callee.type}`); - } - return checkName(name, names); -} - -function checkName(name: string, names: DegeneratorNames): boolean { - // now that we have the `name`, check if any entries match in the `names` array - for (let i = 0; i < names.length; i++) { - const n = names[i]; - if (types.isRegExp(n)) { - if (n.test(name)) { - return true; - } - } else if (name === n) { - return true; - } - } - return false; -} +export * from './degenerator'; +export * from './compile'; diff --git a/packages/degenerator/test/test.ts b/packages/degenerator/test/test.ts index 3322f9bf..056167e9 100644 --- a/packages/degenerator/test/test.ts +++ b/packages/degenerator/test/test.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import assert from 'assert'; import { degenerator, compile } from '../src'; +import { getQuickJS, type QuickJSWASMModule } from '@tootallnate/quickjs-emscripten'; describe('degenerator()', () => { it('should support "async" output functions', () => { @@ -64,79 +65,76 @@ describe('degenerator()', () => { }); describe('`compile()`', () => { + let qjs: QuickJSWASMModule; + + beforeAll(async () => { + qjs = await getQuickJS(); + }); + it('should compile code into an invocable async function', async () => { const a = (v: string) => Promise.resolve(v); const b = () => 'b'; function aPlusB(v: string): string { return a(v) + b(); } - const fn = compile('' + aPlusB, 'aPlusB', ['a'], { + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { + names: ['a'], sandbox: { a, b }, }); const val = await fn('c'); assert.equal(val, 'cb'); }); - it('should contain the compiled code in `toString()` output', () => { + it('should contain the compiled code in `toString()` output', async () => { const a = () => 'a'; const b = () => 'b'; function aPlusB(): string { return a() + b(); } - const fn = compile<() => Promise>( - '' + aPlusB, - 'aPlusB', - ['b'], - { - sandbox: { a, b }, - } - ); + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { + names: ['b'], + sandbox: { a, b }, + }); assert(/await b\(\)/.test(fn + '')); }); - it('should be able to await non-promises', () => { + it('should be able to await non-promises', async () => { const a = () => 'a'; const b = () => 'b'; function aPlusB(): string { return a() + b(); } - const fn = compile<() => Promise>( - '' + aPlusB, - 'aPlusB', - ['a'], - { - sandbox: { a, b }, - } - ); - return fn().then((val) => { - assert.equal(val, 'ab'); + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { + names: ['a'], + sandbox: { a, b }, }); + const val = await fn(); + assert.equal(val, 'ab'); }); - it('should be able to compile functions with no async', () => { + it('should be able to compile functions with no async', async () => { const a = () => 'a'; const b = () => 'b'; function aPlusB(): string { return a() + b(); } - const fn = compile('' + aPlusB, 'aPlusB', [], { + const fn = compile(qjs, '' + aPlusB, 'aPlusB', { sandbox: { a, b }, }); - return fn().then((val: string) => { - assert.equal(val, 'ab'); - }); + const val = await fn(); + assert.equal(val, 'ab'); }); - it('should throw an Error if no function is returned from the `vm`', () => { + it('should throw an Error if no function is returned from the `vm`', async () => { let err: Error | undefined; try { - compile<() => Promise>('const foo = 1', 'foo', []); + compile<() => Promise>(qjs, 'const foo = 1', 'foo'); } catch (_err) { err = _err as Error; } assert(err); assert.equal( err.message, - 'Expected a "function" to be returned for `foo`, but got "number"' + 'Expected a "function" named `foo` to be defined, but got "number"' ); }); - it('should compile if branches', () => { + it('should compile if branches', async () => { function ifA(): string { if (a()) { return 'foo'; @@ -152,43 +150,42 @@ describe('degenerator()', () => { function b() { return false; } - const fn = compile(`${ifA};${a}`, 'ifA', ['b'], { + const fn = compile(qjs, `${ifA};${a}`, 'ifA', { + names: ['b'], sandbox: { b }, }); - return fn().then((val: string) => { - assert.equal(val, 'foo'); - }); + const val = await fn(); + assert.equal(val, 'foo'); }); it('should prevent privilege escalation of untrusted code', async () => { let err: Error | undefined; try { const fn = compile( + qjs, `const f = this.constructor.constructor('return process');`, - 'f', - [] + 'f' ); await fn(); } catch (_err) { err = _err as Error; } assert(err); - assert.equal(err.message, 'process is not defined'); + assert.equal(err.message, "'process' is not defined"); }); - it('should allow to return synchronous undefined', () => { + it('should allow to return synchronous undefined', async () => { function u() { // empty } - const fn = compile(`${u}`, 'u', ['']); - return fn().then((val) => { - assert.strictEqual(val, undefined); - }); + const fn = compile(qjs, `${u}`, 'u'); + const val = await fn(); + assert.strictEqual(typeof val, 'undefined'); }); it('should support "filename" option', async () => { function u() { throw new Error('fail'); } let err: Error | undefined; - const fn = compile(`${u}`, 'u', [''], { + const fn = compile(qjs, `${u}`, 'u', { filename: '/foo/bar/baz.js', }); try { diff --git a/packages/pac-proxy-agent/package.json b/packages/pac-proxy-agent/package.json index 2e524376..3fdf977f 100644 --- a/packages/pac-proxy-agent/package.json +++ b/packages/pac-proxy-agent/package.json @@ -31,6 +31,7 @@ "author": "Nathan Rajlich (http://n8.io/)", "license": "MIT", "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.0.2", "debug": "^4.3.4", "get-uri": "^6.0.1", diff --git a/packages/pac-proxy-agent/src/index.ts b/packages/pac-proxy-agent/src/index.ts index 8bf869cb..76699ae7 100644 --- a/packages/pac-proxy-agent/src/index.ts +++ b/packages/pac-proxy-agent/src/index.ts @@ -20,6 +20,7 @@ import { FindProxyForURL, PacResolverOptions, } from 'pac-resolver'; +import { getQuickJS } from '@tootallnate/quickjs-emscripten'; const debug = createDebug('pac-proxy-agent'); @@ -116,7 +117,10 @@ export class PacProxyAgent extends Agent { private async loadResolver(): Promise { try { // (Re)load the contents of the PAC file URI - const code = await this.loadPacFile(); + const [qjs, code] = await Promise.all([ + getQuickJS(), + this.loadPacFile(), + ]); // Create a sha1 hash of the JS code const hash = crypto.createHash('sha1').update(code).digest('hex'); @@ -130,7 +134,7 @@ export class PacProxyAgent extends Agent { // Cache the resolver debug('Creating new proxy resolver instance'); - this.resolver = createPacResolver(code, this.opts); + this.resolver = createPacResolver(qjs, code, this.opts); // Store that sha1 hash for future comparison purposes this.resolverHash = hash; diff --git a/packages/pac-resolver/README.md b/packages/pac-resolver/README.md index 67bc58df..a7ea4db4 100644 --- a/packages/pac-resolver/README.md +++ b/packages/pac-resolver/README.md @@ -39,7 +39,7 @@ console.log(res); API --- -### pac(pacFileContents: string | Buffer, options?: PacResolverOptions) → Function +### pac(qjs: QuickJSWASMModule, pacFileContents: string | Buffer, options?: PacResolverOptions) → Function Returns an asynchronous `FindProxyForURL()` function based off of the given JS string `pacFileContents` PAC proxy file. An optional `options` object may be @@ -53,6 +53,8 @@ passed in which respects the following options: instance, and the JS code will be able to invoke the function as if it were synchronous. + The `qjs` parameter is a QuickJS module instance as returned from `getQuickJS()` from the `quickjs-emscripten` module. + License ------- diff --git a/packages/pac-resolver/package.json b/packages/pac-resolver/package.json index 9ba2839b..98277af1 100644 --- a/packages/pac-resolver/package.json +++ b/packages/pac-resolver/package.json @@ -13,6 +13,7 @@ "netmask": "^2.0.2" }, "devDependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", "@types/ip": "^1.1.0", "@types/jest": "^29.5.2", "@types/netmask": "^1.0.30", diff --git a/packages/pac-resolver/src/index.ts b/packages/pac-resolver/src/index.ts index 1f83d545..13b6e996 100644 --- a/packages/pac-resolver/src/index.ts +++ b/packages/pac-resolver/src/index.ts @@ -16,12 +16,14 @@ import myIpAddress from './myIpAddress'; import shExpMatch from './shExpMatch'; import timeRange from './timeRange'; import weekdayRange from './weekdayRange'; +import type { QuickJSWASMModule } from '@tootallnate/quickjs-emscripten'; /** * Returns an asynchronous `FindProxyForURL()` function * from the given JS string (from a PAC file). */ export function createPacResolver( + qjs: QuickJSWASMModule, _str: string | Buffer, _opts: PacResolverOptions = {} ) { @@ -33,22 +35,23 @@ export function createPacResolver( ..._opts.sandbox, }; + // Construct the array of async function names to add `await` calls to. + const names = Object.keys(context).filter((k) => + isAsyncFunction(context[k]) + ); + const opts: PacResolverOptions = { filename: 'proxy.pac', + names, ..._opts, sandbox: context, }; - // Construct the array of async function names to add `await` calls to. - const names = Object.keys(context).filter((k) => - isAsyncFunction(context[k]) - ); - // Compile the JS `FindProxyForURL()` function into an async function. const resolver = compile( + qjs, str, 'FindProxyForURL', - names, opts ); @@ -168,13 +171,6 @@ export const sandbox = Object.freeze({ weekdayRange, }); -function toCallback( - promise: Promise, - callback: (err: Error | null, result?: T) => void -): void { - promise.then((rtn) => callback(null, rtn), callback); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any function isAsyncFunction(v: any): boolean { if (typeof v !== 'function') return false; diff --git a/packages/pac-resolver/test/test.ts b/packages/pac-resolver/test/test.ts index fba8b4b5..db718cc5 100644 --- a/packages/pac-resolver/test/test.ts +++ b/packages/pac-resolver/test/test.ts @@ -2,10 +2,20 @@ import assert from 'assert'; import { resolve } from 'path'; import { readFileSync } from 'fs'; import { createPacResolver } from '../src'; +import { getQuickJS, type QuickJSWASMModule } from '@tootallnate/quickjs-emscripten'; + +type FindProxyForURLFn = ReturnType; describe('FindProxyForURL', () => { + let qjs: QuickJSWASMModule; + + beforeAll(async () => { + qjs = await getQuickJS(); + }); + it('should return `undefined` by default', async () => { const FindProxyForURL = createPacResolver( + qjs, 'function FindProxyForURL (url, host) {' + ' /* noop */' + '}' ); const res = await FindProxyForURL('http://foo.com/', 'foo.com'); @@ -14,6 +24,7 @@ describe('FindProxyForURL', () => { it('should return the value that gets returned', async () => { const FindProxyForURL = createPacResolver( + qjs, 'function FindProxyForURL (url, host) {' + ' return { foo: "bar" };' + '}' @@ -28,6 +39,7 @@ describe('FindProxyForURL', () => { } const opts = { sandbox: { foo } }; const FindProxyForURL = createPacResolver( + qjs, 'function FindProxyForURL (url, host) { return typeof foo; }', opts ); @@ -40,6 +52,7 @@ describe('FindProxyForURL', () => { let err: Error | undefined; try { createPacResolver( + qjs, `// Real PAC config: function FindProxyForURL(url, host) { return "DIRECT"; @@ -57,19 +70,24 @@ describe('FindProxyForURL', () => { err = _err as Error; } assert(err); - expect(err.message).toEqual('process is not defined'); + expect(err.message).toEqual("'process' is not defined"); }); describe('official docs Example #1', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host) {' + - ' if (isPlainHostName(host) ||' + - ' dnsDomainIs(host, ".netscape.com"))' + - ' return "DIRECT";' + - ' else' + - ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + - '}' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host) {' + + ' if (isPlainHostName(host) ||' + + ' dnsDomainIs(host, ".netscape.com"))' + + ' return "DIRECT";' + + ' else' + + ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + + '}' + ); + }); it('should return "DIRECT" for "localhost"', async () => { const res = await FindProxyForURL( @@ -97,18 +115,23 @@ describe('FindProxyForURL', () => { }); describe('official docs Example #1b', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host)' + - '{' + - ' if ((isPlainHostName(host) ||' + - ' dnsDomainIs(host, ".netscape.com")) &&' + - ' !localHostOrDomainIs(host, "www.netscape.com") &&' + - ' !localHostOrDomainIs(host, "merchant.netscape.com"))' + - ' return "DIRECT";' + - ' else' + - ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + - '}' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host)' + + '{' + + ' if ((isPlainHostName(host) ||' + + ' dnsDomainIs(host, ".netscape.com")) &&' + + ' !localHostOrDomainIs(host, "www.netscape.com") &&' + + ' !localHostOrDomainIs(host, "merchant.netscape.com"))' + + ' return "DIRECT";' + + ' else' + + ' return "PROXY w3proxy.netscape.com:8080; DIRECT";' + + '}' + ); + }); it('should return "DIRECT" for "localhost"', async () => { const res = await FindProxyForURL( @@ -144,27 +167,32 @@ describe('FindProxyForURL', () => { }); describe('official docs Example #5', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host)' + - '{' + - ' if (url.substring(0, 5) == "http:") {' + - ' return "PROXY http-proxy.mydomain.com:8080";' + - ' }' + - ' else if (url.substring(0, 4) == "ftp:") {' + - ' return "PROXY ftp-proxy.mydomain.com:8080";' + - ' }' + - ' else if (url.substring(0, 7) == "gopher:") {' + - ' return "PROXY gopher-proxy.mydomain.com:8080";' + - ' }' + - ' else if (url.substring(0, 6) == "https:" ||' + - ' url.substring(0, 6) == "snews:") {' + - ' return "PROXY security-proxy.mydomain.com:8080";' + - ' }' + - ' else {' + - ' return "DIRECT";' + - ' }' + - '}' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host)' + + '{' + + ' if (url.substring(0, 5) == "http:") {' + + ' return "PROXY http-proxy.mydomain.com:8080";' + + ' }' + + ' else if (url.substring(0, 4) == "ftp:") {' + + ' return "PROXY ftp-proxy.mydomain.com:8080";' + + ' }' + + ' else if (url.substring(0, 7) == "gopher:") {' + + ' return "PROXY gopher-proxy.mydomain.com:8080";' + + ' }' + + ' else if (url.substring(0, 6) == "https:" ||' + + ' url.substring(0, 6) == "snews:") {' + + ' return "PROXY security-proxy.mydomain.com:8080";' + + ' }' + + ' else {' + + ' return "DIRECT";' + + ' }' + + '}' + ); + }); it('should return "DIRECT" for "foo://netscape.com"', async () => { const res = await FindProxyForURL( @@ -216,30 +244,35 @@ describe('FindProxyForURL', () => { }); describe('GitHub issue #3', () => { - const FindProxyForURL = createPacResolver( - 'function FindProxyForURL(url, host) {\n' + - ' if (isHostInAnySubnet(host, ["10.1.2.0", "10.1.3.0"], "255.255.255.0")) {\n' + - ' return "HTTPS proxy.example.com";\n' + - ' }\n' + - '\n' + - ' if (isHostInAnySubnet(host, ["10.2.2.0", "10.2.3.0"], "255.255.255.0")) {\n' + - ' return "HTTPS proxy.example.com";\n' + - ' }\n' + - '\n' + - ' // Everything else, go direct:\n' + - ' return "DIRECT";\n' + - '}\n' + - '\n' + - '// Checks if the single host is within a list of subnets using the single mask.\n' + - 'function isHostInAnySubnet(host, subnets, mask) {\n' + - ' var subnets_length = subnets.length;\n' + - ' for (i = 0; i < subnets_length; i++) {\n' + - ' if (isInNet(host, subnets[i], mask)) {\n' + - ' return true;\n' + - ' }\n' + - ' }\n' + - '}\n' - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + 'function FindProxyForURL(url, host) {\n' + + ' if (isHostInAnySubnet(host, ["10.1.2.0", "10.1.3.0"], "255.255.255.0")) {\n' + + ' return "HTTPS proxy.example.com";\n' + + ' }\n' + + '\n' + + ' if (isHostInAnySubnet(host, ["10.2.2.0", "10.2.3.0"], "255.255.255.0")) {\n' + + ' return "HTTPS proxy.example.com";\n' + + ' }\n' + + '\n' + + ' // Everything else, go direct:\n' + + ' return "DIRECT";\n' + + '}\n' + + '\n' + + '// Checks if the single host is within a list of subnets using the single mask.\n' + + 'function isHostInAnySubnet(host, subnets, mask) {\n' + + ' var subnets_length = subnets.length;\n' + + ' for (i = 0; i < subnets_length; i++) {\n' + + ' if (isInNet(host, subnets[i], mask)) {\n' + + ' return true;\n' + + ' }\n' + + ' }\n' + + '}\n' + ); + }); it('should return "HTTPS proxy.example.com" for "http://10.1.2.3/bar.html"', async () => { const res = await FindProxyForURL( @@ -261,9 +294,14 @@ describe('FindProxyForURL', () => { // https://github.com/breakwa11/gfw_whitelist // https://github.com/TooTallNate/node-pac-resolver/issues/20 describe('GitHub issue #20', () => { - const FindProxyForURL = createPacResolver( - readFileSync(resolve(__dirname, 'fixtures/gfw_whitelist.pac')) - ); + let FindProxyForURL: FindProxyForURLFn; + + beforeAll(() => { + FindProxyForURL = createPacResolver( + qjs, + readFileSync(resolve(__dirname, 'fixtures/gfw_whitelist.pac')) + ); + }); it('should return "DIRECT" for "https://example.cn"', async () => { const res = await FindProxyForURL('https://example.cn/'); @@ -283,7 +321,7 @@ describe('FindProxyForURL', () => { it('should include `proxy.pac` in stack traces by default', async () => { let err: Error | undefined; - const FindProxyForURL = createPacResolver(code); + const FindProxyForURL = createPacResolver(qjs, code); try { await FindProxyForURL('https://example.com/'); } catch (_err) { @@ -298,7 +336,7 @@ describe('FindProxyForURL', () => { it('should include `fail.pac` in stack traces by option', async () => { let err: Error | undefined; - const FindProxyForURL = createPacResolver(code, { + const FindProxyForURL = createPacResolver(qjs, code, { filename: 'fail.pac', }); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bb2f474..0bd65b0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,10 +105,10 @@ importers: esprima: specifier: ^4.0.1 version: 4.0.1 - vm2: - specifier: ^3.9.19 - version: 3.9.19 devDependencies: + '@tootallnate/quickjs-emscripten': + specifier: ^0.23.0 + version: 0.23.0 '@types/escodegen': specifier: ^0.0.6 version: 0.0.6 @@ -268,6 +268,9 @@ importers: packages/pac-proxy-agent: dependencies: + '@tootallnate/quickjs-emscripten': + specifier: ^0.23.0 + version: 0.23.0 agent-base: specifier: ^7.0.2 version: link:../agent-base @@ -333,6 +336,9 @@ importers: specifier: ^2.0.2 version: 2.0.2 devDependencies: + '@tootallnate/quickjs-emscripten': + specifier: ^0.23.0 + version: 0.23.0 '@types/ip': specifier: ^1.1.0 version: 1.1.0 @@ -1763,6 +1769,9 @@ packages: '@sinonjs/commons': 2.0.0 dev: true + /@tootallnate/quickjs-emscripten@0.23.0: + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + /@types/agent-base@4.2.0: resolution: {integrity: sha512-8mrhPstU+ZX0Ugya8tl5DsDZ1I5ZwQzbL/8PA0z8Gj0k9nql7nkaMzmPVLj+l/nixWaliXi+EBiLA8bptw3z7Q==} dependencies: @@ -2128,23 +2137,12 @@ packages: acorn: 7.4.1 dev: true - /acorn-walk@8.2.0: - resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} - engines: {node: '>=0.4.0'} - dev: false - /acorn@7.4.1: resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} engines: {node: '>=0.4.0'} hasBin: true dev: true - /acorn@8.9.0: - resolution: {integrity: sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==} - engines: {node: '>=0.4.0'} - hasBin: true - dev: false - /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -5762,15 +5760,6 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /vm2@3.9.19: - resolution: {integrity: sha512-J637XF0DHDMV57R6JyVsTak7nIL8gy5KH4r1HiwWLf/4GBbb5MKL5y7LpmF4A8E2nR6XmzpmMFQ7V7ppPTmUQg==} - engines: {node: '>=6.0'} - hasBin: true - dependencies: - acorn: 8.9.0 - acorn-walk: 8.2.0 - dev: false - /walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} dependencies: