Extension Security & CSP Hardening
Harden an MV3 extension against its own supply chain and the pages it touches: the default content security policy, remote code rules, sandboxed pages, message origin validation, and safe remote configuration.
An extension runs with permissions no web page can obtain, which makes every input it accepts a privilege-escalation opportunity. Manifest V3 removes the two worst offenders by policy — remotely hosted code and eval are simply not available — but the remaining surface is still wide: messages arriving from content scripts, markup scraped from pages, configuration fetched from your own server. This guide is part of Manifest V3 Architecture & Extension Lifecycle and works through the policy you inherit, the boundaries you must validate, and the patterns that keep remote configuration from becoming remote code.
Prerequisites checklist
- A
manifest_versionof 3. The strict default policy described here is not opt-in; it is what MV3 pages get. - All scripts in external files. Inline
<script>blocks andonclickattributes are blocked in every extension page, including the popup and options page. - A build that emits no
eval. Some bundler configurations still emiteval-based module wrappers in development mode; ship the production configuration. - Message handlers that check their sender. The message passing layer delivers to whoever is listening, so authorisation is your job.
- The narrowest permissions that work. Hardening starts with not holding the capability — see store submission and permissions compliance.
Manifest registration
1{
2 "manifest_version": 3,
3 "name": "Field Notes",
4 "version": "3.1.0",
5
6 // Extension pages: the MV3 default already forbids remote script and eval. Restating it
7 // documents the intent and lets you tighten further — here, no remote connections at all.
8 "content_security_policy": {
9 "extension_pages":
10 "script-src 'self'; object-src 'self'; connect-src 'self' https://api.example.com; base-uri 'none'",
11 // Sandboxed pages are the ONLY place a looser script policy is permitted, and they get no
12 // chrome.* APIs and no extension origin in exchange.
13 "sandbox":
14 "sandbox allow-scripts; script-src 'self' 'unsafe-eval'; object-src 'none'"
15 },
16
17 "sandbox": {
18 "pages": ["sandbox/template-renderer.html"]
19 },
20
21 "permissions": ["storage"],
22 "host_permissions": [], // nothing broad; features ask at runtime instead
23 "optional_host_permissions": ["https://*/*"],
24
25 "background": { "service_worker": "background.js", "type": "module" }
26}
Execution context: Parsed at install time. extension_pages cannot be loosened — a manifest that tries to add 'unsafe-eval' there is rejected at load, and the store rejects it at review. connect-src is worth setting explicitly: the default allows any origin, and narrowing it turns an accidental exfiltration path into a console error. Firefox honours the same keys; Safari enforces the policy but reports violations less verbosely, so test policy changes in Chrome first.
1. What the default policy already blocks
Most of the hardening work in MV3 is done for you, and knowing exactly which constructs are dead saves rediscovering it through a broken build. The important corollary is that a library which needs any of these will not work in an extension page at all — no configuration flag recovers it.
1// Every line below fails in an MV3 extension page. Kept as a compile-time reference, not advice.
2eval('1 + 1'); // blocked: no 'unsafe-eval'
3new Function('return 1')(); // blocked: same rule
4setTimeout('doThing()', 0); // blocked: string form is eval
5document.body.innerHTML = untrusted; // allowed by CSP, unsafe by design
6const s = document.createElement('script');
7s.src = 'https://cdn.example.com/lib.js'; // blocked: remote script
8document.head.append(s);
Execution context: Any extension page — popup, options, offscreen document or sandboxed frame. The first three throw EvalError; the remote script is refused by the policy and logged as a CSP violation in that page’s own console, which is easy to miss because each extension surface has a separate DevTools window. The innerHTML line is the interesting one: CSP does not stop it, because CSP is about code loading, not about DOM injection. Guarding that is your job.
2. Validating what arrives on the message bus
chrome.runtime.onMessage fires for messages from your popup, your options page, your offscreen document, your content scripts — and, if you declare externally_connectable, from web pages and other extensions. A handler that switches on message.type without looking at sender is an unauthenticated remote procedure call into a context holding all of your permissions.
1// background.js — one gate in front of every handler
2const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
3
4function senderIsTrusted(sender) {
5 // Our own extension surfaces: popup, options, offscreen.
6 if (sender.id === chrome.runtime.id && !sender.tab) return { kind: 'own-page' };
7
8 // Our own content script — trusted as code, but its input is not.
9 if (sender.id === chrome.runtime.id && sender.tab) {
10 return { kind: 'content-script', origin: sender.origin ?? null, tabId: sender.tab.id };
11 }
12
13 // Anything reaching us through externally_connectable must be on the allow list.
14 if (sender.origin && ALLOWED_ORIGINS.has(sender.origin)) return { kind: 'web', origin: sender.origin };
15
16 return null;
17}
18
19chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
20 const trust = senderIsTrusted(sender);
21 if (!trust) {
22 sendResponse({ ok: false, error: 'forbidden' });
23 return false;
24 }
25
26 // Privileged operations are reachable from our own pages only, never from a content script.
27 if (message?.type === 'EXPORT_ALL_DATA' && trust.kind !== 'own-page') {
28 sendResponse({ ok: false, error: 'forbidden' });
29 return false;
30 }
31
32 (async () => sendResponse(await handle(message, trust)))();
33 return true;
34});
Execution context: Extension service worker, listener registered at the top level. sender.id is set by the browser and cannot be spoofed by page script; sender.tab is present exactly when the message came from a content script, which is the cheapest way to distinguish your own surfaces from injected ones. sender.origin is available in Chrome 80+ and Firefox; Safari populates it inconsistently, so treat a missing origin as untrusted rather than as a pass.
3. Building DOM from untrusted strings
Content scripts read markup and text from pages you do not control, and the natural way to display it — assigning to innerHTML — hands the page a script-execution primitive inside your own UI. The policy will not save you here, because the extension page’s CSP applies to the markup you inject too, but attribute-based vectors and injected iframes are not covered by script-src alone.
1// A safe render path: structure from code, text from data.
2function renderItems(container, items) {
3 container.replaceChildren(); // no innerHTML anywhere
4
5 for (const item of items) {
6 const row = document.createElement('li');
7 row.className = 'item';
8
9 const title = document.createElement('span');
10 title.textContent = item.title; // text, never markup
11 row.append(title);
12
13 if (isSafeHttpUrl(item.link)) { // validate before it becomes an href
14 const link = document.createElement('a');
15 link.href = item.link;
16 link.rel = 'noopener noreferrer';
17 link.textContent = 'open';
18 row.append(link);
19 }
20
21 container.append(row);
22 }
23}
24
25function isSafeHttpUrl(value) {
26 try {
27 const url = new URL(value);
28 return url.protocol === 'https:' || url.protocol === 'http:';
29 } catch {
30 return false; // javascript: and data: fail the parse or the test
31 }
32}
Execution context: Any extension page or content script. textContent cannot introduce markup, and replaceChildren avoids the string concatenation that makes innerHTML tempting in the first place. The URL check matters independently of CSP: javascript: in an href is a navigation, not a script load, so script-src never sees it. In Chrome you can make the unsafe path unavailable altogether with a Trusted Types policy; Firefox and Safari do not enforce Trusted Types yet, so the manual discipline above is what actually ports.
4. Remote configuration without remote code
Wanting to change behaviour without shipping an update is legitimate; doing it by fetching code is prohibited and will be rejected. The line is whether the payload is interpreted as instructions or read as data — so define a schema, validate against it, and ignore anything you do not recognise.
1// background.js — data-only remote configuration
2const CONFIG_URL = 'https://api.example.com/v1/config';
3
4const DEFAULTS = { pollMinutes: 30, maxItems: 50, blockedHosts: [] };
5
6function sanitiseConfig(raw) {
7 // Every field is copied out explicitly, coerced, and clamped. Unknown keys are dropped.
8 const pollMinutes = Number(raw?.pollMinutes);
9 const maxItems = Number(raw?.maxItems);
10 return {
11 pollMinutes: Number.isFinite(pollMinutes) ? Math.min(Math.max(pollMinutes, 15), 1440) : DEFAULTS.pollMinutes,
12 maxItems: Number.isFinite(maxItems) ? Math.min(Math.max(maxItems, 1), 500) : DEFAULTS.maxItems,
13 blockedHosts: Array.isArray(raw?.blockedHosts)
14 ? raw.blockedHosts.filter((h) => typeof h === 'string' && /^[a-z0-9.-]+$/i.test(h)).slice(0, 200)
15 : DEFAULTS.blockedHosts,
16 };
17}
18
19export async function refreshConfig() {
20 const res = await fetch(CONFIG_URL, { credentials: 'omit', cache: 'no-store' });
21 if (!res.ok) return; // keep the last known good config
22 const config = sanitiseConfig(await res.json());
23 await chrome.storage.local.set({ config });
24}
Execution context: Extension service worker. credentials: 'omit' keeps ambient cookies out of the request, and connect-src in the manifest means a typo in CONFIG_URL fails loudly instead of reaching an unintended host. The allow-list shape of sanitiseConfig is the part reviewers look for: values are coerced and clamped, unknown keys never reach storage, and nothing in the payload can name a function to call. Schedule the refresh with chrome.alarms rather than a timer, so it survives eviction.
5. Sandboxed pages for genuinely dynamic evaluation
Some features really do need to evaluate a user-supplied expression — a formula field, a custom template. A sandboxed page is the supported way to do it: it may use eval, and in exchange it has no extension origin and no chrome.* access, so a successful escape reaches nothing.
1<!-- sandbox/template-renderer.html — no chrome.* here, by design -->
2<!doctype html>
3<meta charset="utf-8">
4<script src="renderer.js"></script>
Execution context: Sandboxed extension page, loaded in an <iframe> from a normal extension page. It runs with a null origin, cannot read chrome.storage, and cannot call chrome.runtime.sendMessage — communication is window.postMessage only, which is why the parent must verify event.source before trusting a reply. Firefox supports sandboxed pages with the same semantics; Safari’s support is partial, so treat the feature as progressive enhancement and keep a non-dynamic fallback.
MV3 constraints to design around
extension_pagescannot be loosened. No'unsafe-eval', no remotescript-src. A manifest that tries fails to load.- Sandboxed pages get no
chrome.*and no extension origin. That is the trade that makes them safe; do not try to bridge it with a permissivepostMessagehandler. - Content scripts run under the page’s CSP. Your policy does not protect them, and a strict page policy can block your own injected styles.
sender.originis not universally populated. Treat absence as untrusted rather than as a pass.- CSP does not stop DOM injection.
innerHTMLwith page-derived strings stays unsafe regardless of policy. - Remote code is a review rejection, not a warning. Data-only configuration is the only remote-update path that ships.
Cross-browser notes
Chrome and Firefox both enforce the MV3 default policy and both accept the content_security_policy object form. Firefox additionally still allows blocking webRequest, so a Firefox-only build can validate requests in code where Chrome needs declarative rules — a real difference in security posture worth documenting in your own threat model. Safari enforces the policy but surfaces violations less clearly, and its sender.origin behaviour is the main portability trap:
1export function trustedOrigin(sender, allowed) {
2 const origin = sender.origin ?? sender.url?.match(/^https?:\/\/[^/]+/)?.[0] ?? null;
3 return origin !== null && allowed.has(origin); // absent origin never passes
4}
Execution context: Shared utility imported by the background context. Falling back to a prefix of sender.url recovers the origin on engines that omit sender.origin, and the explicit null check keeps the failure mode closed rather than open. See cross-browser API compatibility for the wider namespace picture.
Related
- Fixing CSP violations in extension pages — reading and clearing the actual console errors.
- Safely using remote config without remote code — the schema-validation pattern in full.
- Best practices for content script isolation — the other side of the untrusted boundary.
- Offscreen Documents & DOM Access — parsing untrusted markup without executing it.
- Manifest V3 Architecture & Extension Lifecycle — the section this guide belongs to.