Replacing Remotely Hosted Code

Remove eval, new Function, remote scripts and code strings from an extension migrating to MV3: data-driven configuration, bundled interpreters, sandboxed pages, and what store reviewers count as remote code.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The MV3 build loads, then the console fills with “Refused to evaluate a string as JavaScript because ‘unsafe-eval’ is not an allowed source of script”. Or the build works and the store rejects it with “Violation: remotely hosted code”. The MV2 extension fetched a script from your CDN to update scraping rules without a store release, used a templating library that compiles templates with new Function, and passed code strings to tabs.executeScript. MV3 forbids all of these. The fix is not to find a loophole — reviewers look for those — but to turn code into data and move the interpreter into the package. This guide belongs to Manifest V2 to V3 migration.

What counts as remotely hosted code

The rule is simple to state: every piece of logic the extension executes must be in the package the store reviewed. Enforcement happens in two places. The browser enforces it technically through the mandatory content security policy — extension pages and the service worker cannot use eval, new Function, string arguments to setTimeout, or scripts from any origin other than the extension itself, and chrome.scripting accepts only functions and packaged files, never strings. The stores enforce it by policy, which is broader: fetching a JSON file of selectors and actions that your code interprets is fine; fetching a JSON file that contains JavaScript source, or a domain-specific language rich enough to be a programming language in disguise, is a violation even if no eval is involved.

Allowed and disallowed patternsCommon MV2 dynamic-code patterns classified as blocked by CSP, rejected by policy, or allowed in MV3.PatternBlocked by CSPRejected by reviewAllowedeval / new FunctionYesYesOnly in sandbox pages<script src=https://…>YesYesNoexecuteScript code stringAPI removed—Use func + argsRemote JSON of selectorsNoNoYesRemote JS in a JSON stringIf evaluatedYesNoWebAssembly in packageNeeds wasm-unsafe-evalNoYes
Data is fine; logic must ship in the package.

Step-by-step: turn code into data

1. Find every dynamic-code site, including dependencies

1# Source and bundled output
2grep -rnE "\beval\(|new Function\(|setTimeout\(\s*[\"'\`]|setInterval\(\s*[\"'\`]" src dist
3grep -rnE "executeScript\([^)]*code\s*:" src
4grep -rnE "<script[^>]+src=[\"']https?://" src/**/*.html

Execution context: a terminal. Run the first grep against the built bundles in dist as well as the source, because dependencies — templating engines, older JSON-schema validators, some analytics loaders — compile code internally. Chrome’s CSP violation messages at runtime confirm the list but only for code paths you happen to exercise.

2. Replace remote scripts with remote data

The commonest MV2 pattern was a remote “rules” script that site-specific scrapers or fixers downloaded to stay current without store releases. Express the rules as data and ship the interpreter.

 1// MV2: remote JS evaluated in the background page
 2// fetch("https://cdn.acme.example/rules.js").then(r => r.text()).then(eval);
 3
 4// MV3: remote data, local interpreter
 5const RULES_URL = "https://cdn.acme.example/rules.v3.json";
 6
 7export async function loadRules() {
 8  const res = await fetch(RULES_URL, { cache: "no-cache" });
 9  const rules = await res.json();
10  return rules.filter(isValidRule);           // schema-check every field
11}
12
13function isValidRule(r) {
14  return typeof r.host === "string"
15    && typeof r.selector === "string" && r.selector.length < 500
16    && ["hide", "highlight", "extractText"].includes(r.action);
17}

Execution context: the service worker. The rule format is deliberately small — a host, a CSS selector, an action from a fixed list. Actions are implemented in packaged code; the remote file can only choose among them. That boundary is what keeps the design within policy: if the remote file could express loops, conditionals and arbitrary calls, it would be a scripting language and reviewers would treat it as remote code. Validation also protects users if the CDN is ever compromised.

Remote configuration with a local interpreterThe worker fetches a JSON rules file, validates each rule against a fixed schema, stores the valid ones, and packaged content-script code applies only the predefined actions those rules select.rules.v3.jsonCDN, data onlyValidatefixed schemastorage.locallast good rulescontent script reads the rulesMatch hostrule.hostquerySelectorAllrule.selectorACTIONS[rule.action]packaged code
The remote file chooses among behaviours; it never defines new ones.

3. Replace eval-based libraries

1// Templating: precompile at build time instead of compiling at runtime
2// vite.config.js — using a plugin that turns .hbs templates into JS modules
3import handlebars from "vite-plugin-handlebars-precompile";
4export default { plugins: [handlebars()] };
5
6// popup.js
7import itemTemplate from "./templates/item.hbs";   // already a function at build time
8list.innerHTML = itemTemplate({ items });          // still sanitise data, see below

Execution context: the build pipeline and an extension page. Precompiling templates moves the new Function call from the user’s browser to your build machine, where CSP does not apply. Many libraries offer a “CSP-safe” build — Vue’s runtime-only build, Ajv’s standalone validation code, lodash without _.template — which is usually a configuration change rather than a rewrite. If precompilation is impossible, step 5’s sandbox is the fallback.

4. Replace code strings in executeScript

1// MV2
2chrome.tabs.executeScript(tabId, { code: `document.body.style.zoom = ${level}` });
3
4// MV3
5chrome.scripting.executeScript({
6  target: { tabId },
7  func: (z) => { document.body.style.zoom = String(z); },
8  args: [level],
9});

Execution context: the service worker. func is serialised from your packaged source, so it is reviewed code; args carries the data, structured-cloned rather than interpolated into source text — which also removes the injection vulnerability the string version had. See replacing tabs.executeScript with chrome.scripting.

5. Use a sandboxed page when evaluation is unavoidable

Some features genuinely need runtime evaluation — a user-written formula language, a legacy template engine with no precompiler. Sandboxed pages may use eval, at the cost of having no extension API access.

1{
2  "sandbox": { "pages": ["sandbox.html"] },
3  "content_security_policy": {
4    "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; object-src 'self'"
5  }
6}
1// offscreen.js or an extension page hosting <iframe src="sandbox.html">
2frame.contentWindow.postMessage({ id, template, data }, "*");
3window.addEventListener("message", (e) => {
4  if (e.source === frame.contentWindow) resolve(e.data.id, e.data.html);
5});

Execution context: an extension page that embeds the sandbox in an iframe, and the sandbox page itself, which runs with a unique opaque origin and no chrome.* APIs. The code it evaluates must still come from the package or from the user — not from your server. Sandboxes are a containment tool for evaluation, not an exception to the remote-code rule. For user-supplied scripts specifically, Chrome provides the userScripts API, covered in running user-supplied code with the userScripts API.

6. Allow WebAssembly explicitly if you use it

1{
2  "content_security_policy": {
3    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
4  }
5}

Execution context: the manifest. Compiling WebAssembly needs 'wasm-unsafe-eval', the one relaxation MV3 permits. The .wasm file must be in the package. Loading WebAssembly modules from a server is remote code.

Where does this dynamic code go?Decision tree for each dynamic-code site found during migration: data-driven behaviour becomes remote JSON with a local interpreter, templates are precompiled, user-written scripts use userScripts, and anything else unavoidable goes into a sandbox.What is the dynamic code for?site rulesRemote JSONlocal interpreterValidate schemafixed actionstemplatesPrecompileat build timeNo runtime evalCSP-safeuser scriptsuserScripts APIuser-supplied onlyDeveloper modeuser opt-inotherSandbox pageno chrome.* accessPackaged input onlynever from server
Most sites become data or precompiled code; the sandbox is the last resort.

Common mistakes

  • Encoding code as a “config language”. A JSON format with if, for, variable assignment and function calls is a scripting language. Reviewers recognise it, and rejections for remote code frequently cite exactly this.
  • Grepping only your own source. Dependencies are the usual offenders. Grep the bundles you ship.
  • Using 'unsafe-eval' in extension_pages. Chrome rejects any MV3 extension-page CSP looser than the minimum; the extension will not load.
  • Loading analytics or support widgets from a CDN. Third-party snippets that inject remote scripts into extension pages are remote code. Use a packaged SDK or a measurement protocol over fetch.
  • Trusting remote data. Configuration from your server can be tampered with in transit or at rest. Validate it as strictly as user input.

Cross-browser variation

  • Chrome / Edge: CSP enforcement plus Chrome Web Store policy on remotely hosted code. 'wasm-unsafe-eval' is the only permitted relaxation for extension pages.
  • Firefox: the same CSP minimum in MV3, and AMO’s policies forbid remote code in MV2 and MV3 alike. AMO reviewers read source, so obfuscated interpreters draw scrutiny.
  • Safari: enforces the extension CSP; App Store review applies Apple’s own rules against downloading executable code, which are at least as strict.

Verification

  1. Load the MV3 build with DevTools open on every extension context and exercise every feature; confirm no CSP violation messages appear.
  2. Grep the final bundles for eval( and new Function( and confirm hits occur only in files loaded by sandboxed pages.
  3. Block your CDN’s hostname and confirm the extension still runs with its last cached rules.
  4. Feed the rule loader a malformed rules file and confirm invalid rules are dropped, not executed.

FAQ

Can I update behaviour without a store release?

You can update data — selectors, thresholds, feature flags, lists. New behaviour needs a release. Designing your rule format to cover anticipated variation is the way to reduce release frequency legitimately.

Is it remote code if I fetch a script but never run it?

No — fetching text is not executing it. Storing JavaScript text with no execution path is pointless, though, and will raise reviewer questions.

Are sandboxed pages allowed to load remote scripts?

The sandbox CSP can technically permit it, but store policy still forbids executing remotely hosted logic. Keep sandboxed code packaged.

Other MV3 Architecture & Extension Lifecycle Resources