Skip to content

Commit

Permalink
fix: tame Error constructor
Browse files Browse the repository at this point in the history
  • Loading branch information
erights committed Jun 30, 2020
1 parent 689a3c1 commit d874a84
Show file tree
Hide file tree
Showing 3 changed files with 241 additions and 2 deletions.
94 changes: 93 additions & 1 deletion packages/ses/src/tame-global-error-object.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,42 @@ export const NativeErrors = [
URIError,
];

// Whitelist names from https://v8.dev/docs/stack-trace-api
// Whitelisting only the names used by error-stack-shim/src/v8StackFrames
// callSiteToFrame to shim the error stack proposal.
const safeV8CallSiteMethodNames = [
// suppress 'getThis' definitely
'getTypeName',
// suppress 'getFunction' definitely
'getFunctionName',
'getMethodName',
'getFileName',
'getLineNumber',
'getColumnNumber',
'getEvalOrigin',
// suppress 'isTopLevel' for now
'isEval',
// suppress 'isNative' for now
'isConstructor',
'isAsync',
// suppress 'isPromiseAll' for now
// suppress 'getPromiseIndex' for now

// Additional names found by experiment, absent from
// https://v8.dev/docs/stack-trace-api

// suppress 'getPosition' for now
// suppress 'getScriptNameOrSourceURL' for now
'toString', // TODO replace to use only whitelisted info
];

const safeV8CallSiteFacet = callSite => {
const methodEntry = name => [name, () => callSite[name]()];
return Object.fromEntries(safeV8CallSiteMethodNames.map(methodEntry));
};

const safeV8SST = sst => sst.map(safeV8CallSiteFacet);

export default function tameGlobalErrorObject(errorTaming = 'safe') {
if (errorTaming !== 'safe' && errorTaming !== 'unsafe') {
throw new Error(`unrecognized errorTaming ${errorTaming}`);
Expand Down Expand Up @@ -38,9 +74,16 @@ export default function tameGlobalErrorObject(errorTaming = 'safe') {
};
*/

let userPrepareStackTrace;

// Use concise methods to obtain named functions without constructors.
const tamedMethods = {
captureStackTrace(error, optFn = undefined) {
// The optional `optFn` argument is for cutting off the bottom of
// the stack --- for capturing the stack only above the topmost
// call to that function. Since this isn't the "real" captureStackTrace
// but instead calls the real one, if no other cutoff is provided,
// we cut this one off.
captureStackTrace(error, optFn = tamedMethods.captureStackTrace) {
if (
errorTaming === 'unsafe' &&
typeof originalError.captureStackTrace === 'function'
Expand All @@ -51,6 +94,12 @@ export default function tameGlobalErrorObject(errorTaming = 'safe') {
}
Reflect.set(error, 'stack', '');
},
prepareStackTrace(error, sst) {
if (typeof userPrepareStackTrace === 'function') {
return userPrepareStackTrace(error, safeV8SST(sst));
}
return [`${error}`, ...sst.map(callSite => `${callSite}`)].join('\n ');
},
};

const ErrorPrototype = originalError.prototype;
Expand Down Expand Up @@ -97,6 +146,39 @@ export default function tameGlobalErrorObject(errorTaming = 'safe') {
enumerable: false,
configurable: true,
},
prepareStackTrace: {
get() {
if (
errorTaming === 'unsafe' &&
typeof userPrepareStackTrace === 'function'
) {
return tamedMethods.prepareStackTrace;
}
// By returning undefined, hopefully this means the VM will next consult
// originalError.prepareStackTrace, even on node despite
// https://bugs.chromium.org/p/v8/issues/detail?id=10551#c3
// or, if absent, fallback to the default behavior.
return undefined;
},
set(newPrepareStackTrace) {
if (errorTaming === 'unsafe') {
if (typeof newPrepareStackTrace === 'function') {
userPrepareStackTrace = newPrepareStackTrace;
originalError.prepareStackTrace = tamedMethods.prepareStackTrace;
} else {
userPrepareStackTrace = undefined;
delete originalError.prepareStackTrace;
}
// We place the useless return on the next line to ensure
// that anything we place after the if in the future only
// happens if the then-case does not.
// eslint-disable-next-line no-useless-return
return;
}
},
enumerable: false,
configurable: true,
},
});

// TODO uncomment. See TODO note above
Expand All @@ -120,6 +202,16 @@ export default function tameGlobalErrorObject(errorTaming = 'safe') {
enumerable: false,
configurable: true,
},
prepareStackTrace: {
get() {
return undefined;
},
set(_) {
// ignore
},
enumerable: false,
configurable: true,
},
});
*/

Expand Down
4 changes: 3 additions & 1 deletion packages/ses/src/whitelist.js
Original file line number Diff line number Diff line change
Expand Up @@ -379,8 +379,10 @@ export default {
prototype: 'ErrorPrototype',
// Non standard, v8 only, used by tap
captureStackTrace: fn,
// Non standard, v8 only, used by tap
// Non standard, v8 only, used by tap, tamed to accessor
stackTraceLimit: accessor,
// Non standard, v8 only, used by several, tamed to accessor
prepareStackTrace: accessor,
},

ErrorPrototype: {
Expand Down
145 changes: 145 additions & 0 deletions packages/ses/test/error-manipulation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/* global lockdown */
import test from 'tape';
import '../src/main.js';

// TODO test Error API in
// * non - start compartments
// * with { errorTaming: 'safe' }
// * on non-v8
lockdown({ errorTaming: 'unsafe' });

// depd (https://github.com/dougwilson/nodejs-depd) uses a stack trace to
// determine the call site of a deprecated function

function simulateDepd() {
function prepareObjectStackTrace(obj, stack) {
return stack;
}

function callSiteLocation(callSite) {
let file = callSite.getFileName() || '<anonymous>';
const line = callSite.getLineNumber();
const colm = callSite.getColumnNumber();
if (callSite.isEval()) {
file = `${callSite.getEvalOrigin()}, ${file}`;
}
const site = [file, line, colm];
site.callSite = callSite;
site.name = callSite.getFunctionName();
return site;
}

function getStack() {
const limit = Error.stackTraceLimit;
const obj = {};
const prep = Error.prepareStackTrace;
Error.prepareStackTrace = prepareObjectStackTrace;
Error.stackTraceLimit = Math.max(10, limit);
// capture the stack
Error.captureStackTrace(obj);
// slice this function off the top
const stack = obj.stack.slice(1);
Error.prepareStackTrace = prep;
Error.stackTraceLimit = limit;
return stack;
}

function middle() {
return getStack();
}

const site = callSiteLocation(middle()[0]);
return site.name;
}

test('Error compatibility - depd', t => {
// the Start Compartment should support this sort of manipulation
const name = simulateDepd();
t.equal(name, 'middle');

// however a new Compartment should not
// const c = new Compartment({ console });
// const sim = c.evaluate(`(${simulateDepd})`);
// t.throws(() => sim(), /Cannot add property prepareStackTrace, object is not extensible/);

t.end();
});

// callstack (https://github.com/nailgun/node-callstack#readme) returns a
// stack as a list of strings, by reading Error().stack
function simulateCallstack() {
function callstack() {
return new Error().stack.split('\n').splice(2);
}
function middle() {
return callstack();
}
return middle();
}

test('Error compatibility - callstack', t => {
const stack = simulateCallstack();
// TODO: upgrade to tape 5.x for t.match
// t.match(stack[0], /at middle/, '"middle" found in callstack() output');
t.notEqual(
stack[0].search(/at middle/),
-1,
'"middle" found in callstack() output',
);

t.end();
});

// callsite (https://www.npmjs.com/package/callsite) returns a list of stack
// frames, obtained by replacing Error.prepareStackTrace .
function simulateCallsite() {
function callsite() {
const orig = Error.prepareStackTrace;
Error.prepareStackTrace = (_, stack) => stack;
const err = new Error();
Error.captureStackTrace(err, callsite);
const { stack } = err;
Error.prepareStackTrace = orig;
return stack;
}

function middle() {
return callsite();
}

return middle()[0].getFunctionName();
}

test('Error compatibility - callsite', t => {
const name = simulateCallsite();
t.equal(name, 'middle');

t.end();
});

// callsites from
// https://github.com/sindresorhus/callsites/blob/master/index.js
// triggers prepareStackTrace by accessing the `.stack` property
// of an error, rather than calling `captureStackTrace`.
function simulateCallsites() {
function callsites() {
const orig = Error.prepareStackTrace;
Error.prepareStackTrace = (_, stack) => stack;
const stack = new Error().stack.slice(1);
Error.prepareStackTrace = orig;
return stack;
}

function middle() {
return callsites();
}

return middle()[0].getFunctionName();
}

test('Error compatibility - callsites', t => {
const name = simulateCallsites();
t.equal(name, 'middle');

t.end();
});

0 comments on commit d874a84

Please sign in to comment.