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
15 changes: 12 additions & 3 deletions src/jsc/bindings/FormatStackTraceForJS.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ using namespace WebCore;

namespace Bun {

// A JSGlobalProxy (globalThis) forwards reads to its target, so a direct write must land on the target.
static JSC::JSObject* stackStorageObject(JSC::JSObject* object)
{
if (object->type() == JSC::GlobalProxyType)
return uncheckedDowncast<JSC::JSGlobalProxy>(object)->target();
return object;
}

// What `.stack` is when the frames do not fit in one string. It cannot throw: `formatStackTrace` also runs in a GC finalizer.
static WTF::String stackTraceHeaderOnly(const WTF::String& name, const WTF::String& message)
{
Expand Down Expand Up @@ -166,7 +174,7 @@ static JSValue formatStackTraceToJSValue(JSC::VM& vm, Zig::GlobalObject* globalO
if (callsPrepareStackTrace) {
// In Node, if you console.log(error.stack) inside Error.prepareStackTrace
// it will display the stack as a formatted string, so we have to do the same.
errorObject->putDirect(vm, vm.propertyNames->stack, stackStringValue, JSC::PropertyAttribute::DontEnum | 0);
stackStorageObject(errorObject)->putDirect(vm, vm.propertyNames->stack, stackStringValue, JSC::PropertyAttribute::DontEnum | 0);

JSC::MarkedArgumentBuffer arguments;
arguments.append(errorObject);
Expand Down Expand Up @@ -840,7 +848,8 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalOb
auto scope = DECLARE_THROW_SCOPE(vm);

JSC::JSValue objectArg = callFrame->argument(0);
if (!objectArg.isObject()) {
// V8 rejects a Proxy too: "stack" would land on the proxy cell, where no trap can see it.
if (!objectArg.isObject() || objectArg.asCell()->type() == JSC::ProxyObjectType) {
Comment thread
robobun marked this conversation as resolved.
Resolved
return JSC::JSValue::encode(throwTypeError(lexicalGlobalObject, scope, "invalid_argument"_s));
}

Expand Down Expand Up @@ -889,7 +898,7 @@ JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalOb
String sourceURL;
JSValue result = computeErrorInfoToJSValue(vm, stackTrace, line, column, sourceURL, errorObject);
RETURN_IF_EXCEPTION(scope, {});
errorObject->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0);
stackStorageObject(errorObject)->putDirect(vm, vm.propertyNames->stack, result, JSC::PropertyAttribute::DontEnum | 0);
}

return JSC::JSValue::encode(JSC::jsUndefined());
Expand Down
71 changes: 71 additions & 0 deletions test/js/node/v8/capture-stack-trace.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,77 @@ test("Error.captureStackTrace installs .stack as non-enumerable", () => {
expectNonEnumerableStack(materialized);
});

test("Error.captureStackTrace rejects a Proxy target like a non-object", () => {
// V8 throws kInvalidArgument for any receiver that is not a plain JSObject,
// which excludes a Proxy. No trap runs and nothing reaches the target.
const observed = [];
const handler = new Proxy(
{},
{
get(_, trap) {
observed.push(trap);
return undefined;
},
},
);
const target = {};
const proxy = new Proxy(target, handler);
const callableProxy = new Proxy(function () {}, handler);
const revocable = Proxy.revocable({}, {});
revocable.revoke();

for (const value of [proxy, callableProxy, revocable.proxy]) {
let caught;
try {
Error.captureStackTrace(value);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(TypeError);
expect(caught.message).toBe("invalid_argument");
}

expect(observed).toEqual([]);
expect(Reflect.ownKeys(target)).toEqual([]);
expect(Reflect.ownKeys(proxy)).toEqual([]);
});

test("Error.captureStackTrace on globalThis installs .stack on the global object", () => {
// globalThis is a proxy in front of the global object. V8 stores "stack" on
// the object behind it, so globalThis.stack reads back as a string.
expect(Object.getOwnPropertyDescriptor(globalThis, "stack")).toBeUndefined();
try {
Error.captureStackTrace(globalThis);
const d = Object.getOwnPropertyDescriptor(globalThis, "stack");
expect({ type: typeof globalThis.stack, enumerable: d?.enumerable, configurable: d?.configurable }).toEqual({
type: "string",
enumerable: false,
configurable: true,
});
expect(globalThis.stack.split("\n")[1]).toContain("capture-stack-trace.test.js");
delete globalThis.stack;

// Error.prepareStackTrace gets the same value the caller passed, and can
// read the default-formatted stack through it. The raw global object
// behind the proxy never reaches script: a strict `this` of it is undefined.
let seen;
Error.prepareStackTrace = (err, sites) => {
seen = {
isGlobalThis: err === globalThis,
stackType: typeof err.stack,
hasOwnStack: Object.prototype.hasOwnProperty.call(err, "stack"),
sites: Array.isArray(sites),
};
return "from-prepare";
};
Error.captureStackTrace(globalThis);
expect(seen).toEqual({ isGlobalThis: true, stackType: "string", hasOwnStack: true, sites: true });
expect(globalThis.stack).toBe("from-prepare");
} finally {
delete globalThis.stack;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
});

test("prepare stack trace call sites", () => {
function f1() {
f2();
Expand Down
Loading