Chrome 扩展开发完全指南 / 第 16 章:安全策略(Security)
第 16 章:安全策略(Security)
安全是 Chrome 扩展开发中不可忽视的核心话题。扩展拥有比普通网页更高的权限,一旦存在安全漏洞,后果严重。本章将全面讲解扩展的安全模型、内容安全策略(CSP)、沙箱机制和安全编码最佳实践。
16.1 扩展安全模型
16.1.1 安全架构
┌───────────────────────────────────────────────────────┐
│ Chrome 浏览器 │
│ │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ 扩展上下文 │ │ 网页上下文 │ │
│ │ (Privileged) │ │ (Unprivileged) │ │
│ │ │ │ │ │
│ │ Service Worker │ │ 页面 JS │ │
│ │ Popup / Options │ │ 页面 DOM │ │
│ │ Side Panel │ │ │ │
│ │ │ │ │ │
│ │ ✅ chrome.* API │ │ ❌ chrome.* API │ │
│ │ ✅ 跨域请求 │ │ ❌ 受同源策略限制 │ │
│ │ ✅ 访问浏览器数据 │ │ ❌ 无法访问 │ │
│ └────────┬───────────┘ └────────────┬────────────┘ │
│ │ │ │
│ │ ┌───────────────┐ │ │
│ │ │Content Script │ │ │
│ └────┤ (有限权限) ├──────┘ │
│ │ │ │
│ │ ✅ DOM 访问 │ │
│ │ ✅ chrome.* │ │
│ │ ❌ 页面变量 │ │
│ └───────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 沙箱 (Sandbox) │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ sandboxed pages │ │ │
│ │ │ ❌ 无 chrome.* API │ │ │
│ │ │ ❌ 无 DOM 访问(受限) │ │ │
│ │ │ ✅ 安全运行不受信任的代码 │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────┘
16.1.2 Manifest V3 安全改进
| MV3 安全措施 | 说明 |
|---|---|
| 禁止远程代码 | 所有代码必须打包在扩展中 |
| Service Worker 限制 | 无 DOM 访问,减少 XSS 风险 |
| 声明式网络请求 | 无法读取请求内容 |
| CSP 严格化 | 不允许 'unsafe-eval' 和 'unsafe-inline' |
| 可选权限 | 最小权限原则 |
16.2 内容安全策略(CSP)
16.2.1 默认 CSP
MV3 扩展的默认 CSP:
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
}
}
这意味着:
- 只能执行扩展包内的脚本
- 只能加载扩展包内的对象(如 Flash、Java)
- 不允许内联脚本(
<script>...</script>) - 不允许
eval()和类似函数 - 不允许远程脚本加载
16.2.2 CSP 配置选项
{
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'",
"sandbox": "sandbox allow-scripts; script-src 'self'"
}
}
| CSP 指令 | 可配置值 | 说明 |
|---|---|---|
script-src | 'self' | 仅允许扩展内脚本 |
object-src | 'self', 'none' | 控制插件加载 |
sandbox | sandbox 属性 | 沙箱页面的策略 |
🚫 警告:MV3 中不允许使用
'unsafe-eval'、'unsafe-inline'或远程脚本 URL。
16.2.3 CSP 合规编码
// ❌ 不允许 — 内联脚本
// <button onclick="handleClick()">Click</button>
// ✅ 正确 — 使用 addEventListener
document.getElementById('btn').addEventListener('click', handleClick);
// ❌ 不允许 — eval()
const fn = new Function('return ' + userInput);
// ✅ 正确 — 使用安全的替代方案
const result = JSON.parse(jsonString);
// ❌ 不允许 — 内联样式(注意:style 属性可能仍可用,但推荐外部样式)
// <div style="color: red">
// ✅ 正确 — 使用外部 CSS 或 classList
element.classList.add('error-style');
// ❌ 不允许 — 字符串模板生成 HTML
element.innerHTML = `<div onclick="${handler}">...</div>`;
// ✅ 正确 — 使用 DOM API 创建元素
const div = document.createElement('div');
div.textContent = '...';
div.addEventListener('click', handler);
16.3 Cross-Site Scripting (XSS) 防御
16.3.1 常见 XSS 攻击向量
// 危险:直接插入用户输入
document.getElementById('output').innerHTML = userInput;
// 危险:从 URL 参数插入
const params = new URLSearchParams(window.location.search);
document.body.innerHTML = params.get('name');
// 危险:从存储中读取并直接渲染
const { template } = await chrome.storage.local.get('template');
container.innerHTML = template;
16.3.2 安全的 DOM 操作
// 安全的文本插入
function safeSetText(element, text) {
element.textContent = text; // 自动转义
}
// 安全的 HTML 构建
function safeCreateElement(tag, attrs = {}, children = []) {
const el = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'textContent') {
el.textContent = value;
} else if (key.startsWith('on')) {
// 不允许内联事件处理器
console.warn('Inline event handlers are not allowed');
} else {
el.setAttribute(key, String(value));
}
}
for (const child of children) {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else if (child instanceof Node) {
el.appendChild(child);
}
}
return el;
}
// 使用示例
const card = safeCreateElement('div', { class: 'card' }, [
safeCreateElement('h2', { textContent: userInput }),
safeCreateElement('p', { textContent: description }),
safeCreateElement('a', {
href: sanitizeUrl(url),
textContent: '了解更多'
})
]);
document.getElementById('container').appendChild(card);
16.3.3 URL 净化
function sanitizeUrl(url) {
try {
const parsed = new URL(url);
// 只允许 http/https 协议
if (!['http:', 'https:'].includes(parsed.protocol)) {
return 'about:blank';
}
return parsed.href;
} catch {
return 'about:blank';
}
}
// 检测 JavaScript: URL
function isDangerousUrl(url) {
const lower = url.toLowerCase().trim();
return lower.startsWith('javascript:') ||
lower.startsWith('data:') ||
lower.startsWith('vbscript:');
}
16.4 沙箱页面
16.4.1 沙箱页面配置
{
"sandbox": {
"pages": [
"sandbox/editor.html",
"sandbox/preview.html"
]
},
"content_security_policy": {
"sandbox": "sandbox allow-scripts; script-src 'self'; style-src 'self' 'unsafe-inline'"
}
}
16.4.2 沙箱页面用途
<!-- sandbox/editor.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Rich Text Editor</title>
<link rel="stylesheet" href="editor.css">
</head>
<body>
<div id="editor" contenteditable="true"></div>
<script src="editor.js"></script>
</body>
</html>
// sandbox/editor.js
// 沙箱中可以安全运行用户提供的 HTML 模板
function renderUserTemplate(template, data) {
// 在沙箱中渲染用户自定义模板
// 即使模板有恶意代码,也无法访问 chrome.* API
const container = document.getElementById('editor');
try {
// 简单模板引擎
let html = template;
for (const [key, value] of Object.entries(data)) {
html = html.replace(
new RegExp(`{{${key}}}`, 'g'),
escapeHtml(String(value))
);
}
container.innerHTML = html;
} catch (error) {
container.textContent = '模板渲染失败';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 与扩展上下文通信
window.addEventListener('message', (event) => {
if (event.data.type === 'render') {
renderUserTemplate(event.data.template, event.data.data);
// 将渲染结果发回
window.parent.postMessage({
type: 'rendered',
html: document.getElementById('editor').innerHTML
}, '*');
}
});
16.5 数据安全
16.5.1 敏感数据存储
// ❌ 不安全 — 将密码明文存储
await chrome.storage.local.set({ password: 'user123' });
// ✅ 更安全 — 使用 Web Crypto API 加密
async function encryptData(data, key) {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const cryptoKey = await crypto.subtle.importKey(
'raw', key, { name: 'AES-GCM' }, false, ['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
cryptoKey,
encoder.encode(JSON.stringify(data))
);
return {
iv: Array.from(iv),
data: Array.from(new Uint8Array(encrypted))
};
}
async function decryptData(encrypted, key) {
const cryptoKey = await crypto.subtle.importKey(
'raw', key, { name: 'AES-GCM' }, false, ['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: new Uint8Array(encrypted.iv) },
cryptoKey,
new Uint8Array(encrypted.data)
);
return JSON.parse(new TextDecoder().decode(decrypted));
}
// 生成加密密钥
async function generateKey() {
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
return await crypto.subtle.exportKey('raw', key);
}
16.5.2 Token 管理
class TokenManager {
constructor() {
this.tokenKey = 'auth_token';
this.refreshKey = 'refresh_token';
}
async saveTokens(accessToken, refreshToken, expiresIn) {
await chrome.storage.session.set({
[this.tokenKey]: accessToken,
[this.refreshKey]: refreshToken,
tokenExpiresAt: Date.now() + expiresIn * 1000
});
}
async getAccessToken() {
const { [this.tokenKey]: token, tokenExpiresAt } =
await chrome.storage.session.get([this.tokenKey, 'tokenExpiresAt']);
if (!token) return null;
// 检查是否过期(提前 60 秒刷新)
if (Date.now() > tokenExpiresAt - 60000) {
return await this.refreshAccessToken();
}
return token;
}
async refreshAccessToken() {
const { [this.refreshKey]: refreshToken } =
await chrome.storage.session.get(this.refreshKey);
if (!refreshToken) return null;
try {
const response = await fetch('https://auth.example.com/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
});
const data = await response.json();
await this.saveTokens(data.access_token, data.refresh_token, data.expires_in);
return data.access_token;
} catch {
await this.clearTokens();
return null;
}
}
async clearTokens() {
await chrome.storage.session.remove([
this.tokenKey, this.refreshKey, 'tokenExpiresAt'
]);
}
}
16.6 安全检查清单
开发阶段
安全检查清单 (开发阶段)
代码安全:
□ 不使用 eval(), new Function(), setTimeout(string)
□ 不使用 innerHTML 直接插入用户输入
□ URL 参数经过净化处理
□ 使用 textContent 而非 innerHTML 显示文本
□ 事件处理器通过 addEventListener 绑定
存储安全:
□ 敏感数据不存储在 localStorage
□ Token 存储在 session storage
□ 本地数据加密存储
通信安全:
□ 验证消息发送者 (sender.id, sender.url)
□ 外部消息验证来源域名
□ 不通过消息传递敏感数据
权限安全:
□ 仅申请必需的权限
□ 使用 activeTab 替代 broad host permissions
□ 可选权限运行时申请
网络安全:
□ 所有 API 调用使用 HTTPS
□ 验证 SSL 证书(fetch 默认验证)
□ 不信任外部返回的数据
发布前检查
安全检查清单 (发布前)
静态分析:
□ 运行 npm audit 检查依赖漏洞
□ 检查是否有遗留的 console.log(可能泄露信息)
□ 确认无内联脚本
动态检查:
□ 测试 XSS 向量(注入 <script> 等)
□ 测试权限边界
□ 测试错误处理
合规检查:
□ 隐私政策已更新
□ 数据收集说明完整
□ 权限使用说明清晰
16.7 常见安全漏洞
| 漏洞类型 | 风险 | 防御措施 |
|---|---|---|
| XSS (跨站脚本) | 高 | 使用 textContent、DOM API、净化输入 |
| 权限提升 | 高 | 最小权限原则、验证消息来源 |
| 数据泄露 | 高 | 加密存储、不通过 URL 传递敏感数据 |
| 中间人攻击 | 中 | 全部使用 HTTPS、验证响应 |
| 代码注入 | 高 | 禁止 eval、禁止远程代码 |
| 点击劫持 | 中 | 验证用户意图、使用确认对话框 |