commit 0ccab1afc3775c6fa748725589d2c88fec5ba008
parent 55970eed95b4e319f0b7bad4e5a480fc17017f01
Author: Julian Descottes <jdescottes@mozilla.com>
Date: Sat, 29 Nov 2025 10:37:40 +0000
Bug 1941780 - Add JSShell test for Frame.eval bypassCSP option r=arai
Differential Revision: https://phabricator.services.mozilla.com/D274097
Diffstat:
2 files changed, 190 insertions(+), 1 deletion(-)
diff --git a/js/src/jit-test/tests/debug/Frame-eval-csp-bypass.js b/js/src/jit-test/tests/debug/Frame-eval-csp-bypass.js
@@ -0,0 +1,154 @@
+// Test that debugger evaluation can bypass CSP restrictions when bypassCSP option is set.
+
+var g = newGlobal({newCompartment: true});
+var dbg = new Debugger(g);
+
+// Create a function to trigger a debugger statement before enabling CSP.
+g.eval("function triggerDebugger() { debugger; }");
+
+// Create functions that will use eval/new Function and which will be called
+// from Frame.eval.
+g.eval("function triggerEval() { return eval('2 + 2'); }");
+g.eval("function triggerNewFunction() { return new Function('return 2 + 2')(); }");
+
+const EXPECTED_VALUE = 4;
+function assertSuccess(expression, options = {}) {
+ let evaluated = false;
+ dbg.onDebuggerStatement = function (frame) {
+ assertEq(frame.eval(expression, options).return, EXPECTED_VALUE);
+ evaluated = true;
+ };
+ g.triggerDebugger();
+ assertEq(evaluated, true);
+}
+
+function assertThrows(expression, options = {}) {
+ let evaluated = false;
+ dbg.onDebuggerStatement = function (frame) {
+ assertEq(frame.eval(expression, options).throw !== undefined, true);
+ evaluated = true;
+ };
+ g.triggerDebugger();
+ assertEq(evaluated, true);
+}
+
+// Smoke test without enabling CSP
+
+// evaluation without eval/new Function always succeeds
+assertSuccess("2 + 2");
+assertSuccess("2 + 2", { bypassCSP: false });
+assertSuccess("2 + 2", { bypassCSP: true });
+
+// Default value for bypassCSP
+assertSuccess("eval('2 + 2')");
+assertSuccess("new Function('return 2 + 2')()");
+assertSuccess("triggerEval()");
+assertSuccess("triggerNewFunction()");
+
+// bypassCSP=false
+assertSuccess("eval('2 + 2')", { bypassCSP: false });
+assertSuccess("new Function('return 2 + 2')()", { bypassCSP: false });
+assertSuccess("triggerEval()", { bypassCSP: false });
+assertSuccess("triggerNewFunction()", { bypassCSP: false });
+
+// bypassCSP=true
+assertSuccess("eval('2 + 2')", { bypassCSP: true });
+assertSuccess("new Function('return 2 + 2')()", { bypassCSP: true });
+assertSuccess("triggerEval()", { bypassCSP: true });
+assertSuccess("triggerNewFunction()", { bypassCSP: true });
+
+// Enable CSP
+setCSPEnabled(true);
+
+// evaluation without eval/new Function always succeeds
+assertSuccess("2 + 2");
+assertSuccess("2 + 2", { bypassCSP: false });
+assertSuccess("2 + 2", { bypassCSP: true });
+
+// Default value for bypassCSP, should all fail
+assertThrows("eval('2 + 2')");
+assertThrows("new Function('return 2 + 2')()");
+assertThrows("triggerEval()");
+assertThrows("triggerNewFunction()");
+
+// bypassCSP=false, should all fail
+assertThrows("eval('2 + 2')", { bypassCSP: false });
+assertThrows("new Function('return 2 + 2')()", { bypassCSP: false });
+assertThrows("triggerEval()", { bypassCSP: false });
+assertThrows("triggerNewFunction()", { bypassCSP: false });
+
+// bypassCSP=true, should all succeed
+assertSuccess("eval('2 + 2')", { bypassCSP: true });
+assertSuccess("new Function('return 2 + 2')()", { bypassCSP: true });
+assertSuccess("triggerEval()", { bypassCSP: true });
+assertSuccess("triggerNewFunction()", { bypassCSP: true });
+
+// Define a few functions using eval with and without bypassCSP.
+// They should both behave the same when triggered from a later Frame.eval, and
+// only the bypassCSP flag for the later Frame.eval should matter.
+dbg.onDebuggerStatement = function (frame) {
+ frame.eval("globalThis.fnWithBypass = () => eval('2 + 2')", { bypassCSP: true });
+ frame.eval("globalThis.fnWithoutBypass = () => eval('2 + 2')", { bypassCSP: false });
+};
+g.triggerDebugger();
+
+// Call the function defined with bypassCSP=true, should only work when
+// bypassCSP is true for the current Frame.eval.
+assertThrows("globalThis.fnWithBypass()");
+assertThrows("globalThis.fnWithBypass()", { bypassCSP: false });
+assertSuccess("globalThis.fnWithBypass()", { bypassCSP: true });
+
+// Call the function defined with bypassCSP=false, should only work when
+// bypassCSP is true for the current Frame.eval.
+assertThrows("globalThis.fnWithoutBypass()");
+assertThrows("globalThis.fnWithoutBypass()", { bypassCSP: false });
+assertSuccess("globalThis.fnWithoutBypass()", { bypassCSP: true });
+
+// Test for async frames.
+const asyncEvalExpression = `(async function() {
+try {
+ globalThis.evalBeforeAwaitSuccess = false;
+ globalThis.evalAfterAwaitSuccess = false;
+ eval("1 + 1");
+ globalThis.evalBeforeAwaitSuccess = true;
+ await 1;
+ eval("2 + 2");
+ globalThis.evalAfterAwaitSuccess = true;
+} catch {}
+})()`;
+
+// Test async code with bypassCSP=true, only the eval before the await should be
+// successful.
+dbg.onDebuggerStatement = function (frame) {
+ frame.onPop = completion => {
+ frame.eval(asyncEvalExpression, { bypassCSP: true });
+ dbg.removeDebuggee(g); // avoid the DebuggeeWouldRun exception
+ drainJobQueue();
+ dbg.addDebuggee(g);
+ }
+};
+g.triggerDebugger();
+
+dbg.onDebuggerStatement = function (frame) {
+ assertEq(frame.eval("globalThis.evalBeforeAwaitSuccess", options).return, true);
+ assertEq(frame.eval("globalThis.evalAfterAwaitSuccess", options).return, false);
+};
+g.triggerDebugger();
+
+// Test async code with bypassCSP=false, all eval should fail.
+dbg.onDebuggerStatement = function (frame) {
+ frame.onPop = completion => {
+ frame.eval(asyncEvalExpression, { bypassCSP: false });
+ dbg.removeDebuggee(g); // avoid the DebuggeeWouldRun exception
+ drainJobQueue();
+ dbg.addDebuggee(g);
+ }
+};
+g.triggerDebugger();
+drainJobQueue();
+
+dbg.onDebuggerStatement = function (frame) {
+ assertEq(frame.eval("globalThis.evalBeforeAwaitSuccess", options).return, false);
+ assertEq(frame.eval("globalThis.evalAfterAwaitSuccess", options).return, false);
+};
+g.triggerDebugger();
diff --git a/js/src/shell/js.cpp b/js/src/shell/js.cpp
@@ -960,8 +960,23 @@ class ShellPrincipals final : public JSPrincipals {
static ShellPrincipals fullyTrusted;
};
+// Global CSP state for the shell. When true, CSP restrictions are enforced.
+static bool gCSPEnabled = false;
+
+static bool ContentSecurityPolicyAllows(
+ JSContext* cx, JS::RuntimeCode kind, JS::Handle<JSString*> codeString,
+ JS::CompilationType compilationType,
+ JS::Handle<JS::StackGCVector<JSString*>> parameterStrings,
+ JS::Handle<JSString*> bodyString,
+ JS::Handle<JS::StackGCVector<JS::Value>> parameterArgs,
+ JS::Handle<JS::Value> bodyArg, bool* outCanCompileStrings) {
+ // If CSP is enabled, block string compilation.
+ *outCanCompileStrings = !gCSPEnabled;
+ return true;
+}
+
JSSecurityCallbacks ShellPrincipals::securityCallbacks = {
- nullptr, // contentSecurityPolicyAllows
+ ContentSecurityPolicyAllows,
nullptr, // codeForEvalGets
subsumes};
@@ -1654,6 +1669,20 @@ static bool SetTimeout(JSContext* cx, unsigned argc, Value* vp) {
return true;
}
+static bool SetCSPEnabled(JSContext* cx, unsigned argc, Value* vp) {
+ CallArgs args = CallArgsFromVp(argc, vp);
+
+ if (args.length() != 1) {
+ JS_ReportErrorASCII(cx, "setCSPEnabled() requires one boolean argument");
+ return false;
+ }
+
+ gCSPEnabled = JS::ToBoolean(args[0]);
+
+ args.rval().setUndefined();
+ return true;
+}
+
static const char* telemetryNames[static_cast<int>(JSMetric::Count)] = {
#define LIT(NAME, _) #NAME,
FOR_EACH_JS_METRIC(LIT)
@@ -10571,6 +10600,12 @@ JS_FN_HELP("createUserArrayBuffer", CreateUserArrayBuffer, 1, 0,
"This is currently restricted to require a delay of 0 and will not accept"
"any extra arguments. No return value is given and there is no clearTimeout."),
+ JS_FN_HELP("setCSPEnabled", SetCSPEnabled, 1, 0,
+"setCSPEnabled(enabled)",
+"Enable or disable Content Security Policy restrictions for eval() and Function().\n"
+"When enabled (true), string compilation will be blocked. When disabled (false),\n"
+"string compilation is allowed. Defaults to disabled."),
+
JS_FN_HELP("setPromiseRejectionTrackerCallback", SetPromiseRejectionTrackerCallback, 1, 0,
"setPromiseRejectionTrackerCallback()",
"Sets the callback to be invoked whenever a Promise rejection is unhandled\n"