Treating Content Script Messages as Untrusted
Defend an MV3 service worker against compromised renderers: why content script messages are untrusted, validating sender and shape, operation allow-lists, per-tab scoping, rate limits and never exposing generic proxies.
Table of Contents
The service worker has a handler that fetches any URL a content script asks for and returns the body — convenient for a scraper, and a gift to an attacker. Content scripts run inside web pages’ renderer processes. If a page exploits a renderer bug, it controls everything in that process, including your content script’s ability to send messages to the worker. From that point, every message handler you wrote is an API the attacker can call with any arguments. Chrome’s security model assumes this and isolates the extension’s privileged contexts in separate processes; your handlers have to uphold the same assumption. This guide shows how. It belongs to extension security and CSP hardening.
The threat model
Content scripts share a renderer process with the page they are injected into. Site isolation keeps different sites in different processes, but within one process, a successful exploit of the renderer — a memory-safety bug in the JavaScript engine or layout code — gives the attacker the content script’s capabilities: calling chrome.runtime.sendMessage, opening ports, reading chrome.storage areas the content script can access. The browser still enforces some things: sender.tab, sender.frameId and sender.origin are filled in by the browser process and cannot be forged by a compromised renderer, and content scripts cannot call privileged APIs directly. Everything else — the message body — is attacker-controlled. Even without an exploit, a page can manipulate the DOM your content script reads and influence what it sends.
Step-by-step: hardened handlers
1. Distinguish content scripts from extension pages
1// sw.js
2chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
3 const fromContentScript = Boolean(sender.tab);
4 const fromExtensionPage = !sender.tab && sender.url?.startsWith(chrome.runtime.getURL(""));
5 const handlers = fromContentScript ? CONTENT_HANDLERS : fromExtensionPage ? PAGE_HANDLERS : null;
6 const handler = handlers?.[msg?.type];
7 if (!handler) return; // unknown type or unknown sender
8 run(handler, msg, sender, sendResponse);
9 return true;
10});
Execution context: the service worker. Messages from content scripts carry sender.tab; messages from your popup, options page or side panel do not and have an extension sender.url. Keeping two separate handler maps means privileged operations — “export all data”, “change settings”, “sign out” — are simply not reachable from content scripts, whatever message they send.
2. Validate the shape of every message
1const CONTENT_HANDLERS = {
2 "highlight:save": withSchema(
3 { text: (v) => typeof v === "string" && v.length > 0 && v.length <= 2000 },
4 async ({ text }, sender) => saveHighlight({ text, url: sender.tab.url, tabId: sender.tab.id }),
5 ),
6};
7
8function withSchema(schema, fn) {
9 return (msg, sender) => {
10 for (const [key, ok] of Object.entries(schema)) if (!ok(msg[key])) throw new Error(`invalid ${key}`);
11 return fn(msg, sender);
12 };
13}
Execution context: the service worker. Types, lengths and formats are checked before any work happens; anything unexpected is rejected. Note the URL comes from sender.tab.url — supplied by the browser — rather than from the message, which a compromised content script could set to any site. A schema library such as Zod works equally well; see typed message contracts with TypeScript.
3. Expose operations, never capabilities
1// Dangerous: a generic proxy any compromised page can drive
2// "fetch": ({ url, init }) => fetch(url, init).then((r) => r.text())
3
4// Safe: a specific operation with fixed endpoint and validated input
5"definition:lookup": withSchema(
6 { word: (v) => typeof v === "string" && /^[\p{L}'-]{1,40}$/u.test(v) },
7 async ({ word }) => (await fetch(`https://api.readable.example/v1/define?w=${encodeURIComponent(word)}`)).json(),
8),
Execution context: the service worker. A handler that fetches arbitrary URLs with the extension’s host permissions and returns the response lets an attacker read any site the extension can access — including authenticated pages, with the user’s cookies. Handlers that run arbitrary scripts in tabs, read arbitrary storage keys, or open arbitrary URLs are equally dangerous. Each handler should do one thing with inputs that cannot widen its effect.
4. Scope effects to the sender’s tab and origin
1"tab:setBadge": withSchema({ count: (v) => Number.isInteger(v) && v >= 0 && v < 10000 },
2 async ({ count }, sender) => chrome.action.setBadgeText({ tabId: sender.tab.id, text: String(count) })),
Execution context: the service worker. A content script should affect only its own tab and its own site’s data. Use sender.tab.id rather than a tab id in the message, and key per-site data by sender.origin (or new URL(sender.tab.url).origin) rather than by a host the message claims. A compromised page can then at worst corrupt its own site’s data.
5. Rate-limit per tab
1const buckets = new Map();
2function allow(tabId, limit = 20, perMs = 1000) {
3 const now = Date.now();
4 const b = buckets.get(tabId) ?? { n: 0, t: now };
5 if (now - b.t > perMs) { b.n = 0; b.t = now; }
6 b.n++;
7 buckets.set(tabId, b);
8 return b.n <= limit;
9}
Execution context: the service worker. A hostile page can make its content script send messages in a tight loop to exhaust your API quota, storage write limits or the user’s network. A simple per-tab rate limit, checked before dispatch, contains the damage. Clear entries on tabs.onRemoved.
6. Never trust data read from the page, even by your own script
1// content.js — the page controls the DOM your script reads
2const price = document.querySelector("[data-price]")?.textContent; // attacker-controlled on a hostile site
3chrome.runtime.sendMessage({ type: "price:track", price });
Execution context: a content script. Even an uncompromised content script relays whatever the page shows it. The worker must validate price as untrusted input — a number within bounds — and render it later as text. Hostile pages can also craft DOM that tricks a content script into sending misleading data; design features so that such data can only affect that site’s own records.
7. Review handlers as a public API
Keep all content-script handlers in one file, review additions with the question “what could a hostile page do with this?”, and add tests that send malformed and malicious messages. A short list of narrow, validated operations is the goal.
Common mistakes
- One handler map for all senders. Privileged operations become reachable from pages.
- Trusting URLs or tab ids in the message. Use
senderfields. - Generic proxies. They lend your permissions to any page.
- No validation because “our content script sends it”. A compromised renderer sends anything.
- No rate limits. Loops can exhaust quotas.
Cross-browser variation
- Chrome / Edge:
sender.tab,sender.frameId,sender.originandsender.documentIdare browser-supplied; site isolation separates renderers per site. - Firefox:
senderfields are browser-supplied; Firefox’s site isolation (Fission) provides similar process separation. - Safari: browser-supplied
sender.tabandurl; process isolation differs, so the same defensive handling is warranted.
Verification
- From a content script console, send a privileged message type (e.g.
settings:reset) and confirm it is ignored. - Send
highlight:savewith a 50 KB string and with a non-string; confirm both are rejected. - Send a message claiming another tab id and confirm the effect applies only to the sender’s tab.
- Send 1,000 messages in a loop and confirm the rate limit drops most of them.
FAQ
Are messages from extension pages trusted?
More so — they run in the extension’s process — but validate them anyway; bugs and XSS in extension pages happen.
Does this apply to ports?
Yes. port.sender carries the same browser-supplied fields; validate every port.onMessage payload.
What about onMessageExternal?
External messages from websites are hostile by default; see externally connectable matches and ids.
Related
- Protecting extension messages from web pages — the page-to-content-script boundary.
- Handling errors across message boundaries — rejecting cleanly.
- Making cross-origin fetch requests from an extension — narrow fetch operations.
- Extension security and CSP hardening — the parent topic.