Skip to content

Commit

Permalink
Use quickjs-emscripten instead of vm2 to execute PAC file code (#224
Browse files Browse the repository at this point in the history
)

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.
  • Loading branch information
TooTallNate committed Jul 18, 2023
1 parent f6d42a4 commit f1f3220
Show file tree
Hide file tree
Showing 14 changed files with 509 additions and 371 deletions.
7 changes: 7 additions & 0 deletions .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
5 changes: 5 additions & 0 deletions .changeset/new-goats-arrive.md
@@ -0,0 +1,5 @@
---
'proxy-agent': minor
---

Use QuickJS version of `pac-proxy-agent`
4 changes: 2 additions & 2 deletions packages/degenerator/package.json
Expand Up @@ -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",
Expand Down
135 changes: 135 additions & 0 deletions 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<R = unknown, A extends unknown[] = []>(
qjs: QuickJSWASMModule,
code: string,
returnName: string,
options: CompileOptions = {}
): (...args: A) => Promise<R> {
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<R> {
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}`);
}
176 changes: 176 additions & 0 deletions 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;
}

1 comment on commit f1f3220

@vercel
Copy link

@vercel vercel bot commented on f1f3220 Jul 18, 2023

Choose a reason for hiding this comment

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

Successfully deployed to the following URLs:

proxy-agents – ./

proxy-agents-git-main-tootallnate.vercel.app
proxy-agents.vercel.app
proxy-agents-tootallnate.vercel.app

Please sign in to comment.