前端安全
概述
前端安全是 Web 安全体系中不可忽视的一环。随着单页应用(SPA)、微前端、跨域数据交互等技术的广泛使用,前端应用面临的攻击面也在不断扩展。本文从前端开发者的视角出发,系统梳理常见的前端安全风险及对应的防御措施,帮助开发者在日常编码中建立安全思维。
CORS 跨域机制
同源策略
同源策略(Same-Origin Policy)是浏览器最核心的安全机制。它限制来自不同源的文档或脚本对当前文档的读取能力,防止恶意网站窃取另一个网站的数据。
同源的定义:协议、域名、端口号三者完全一致。
// 假设当前页面为 https://example.com:443/page
// 同源示例
// https://example.com:443/other → 同源
// https://example.com:443/api/data → 同源
// 跨源示例
// http://example.com:443 → 协议不同(http ≠ https)
// https://api.example.com:443 → 域名不同
// https://example.com:8080 → 端口不同同源策略规定:跨源写操作(如表单提交、链接跳转)通常允许,跨源嵌入(如 <script>、<img>、<link> 标签)通常允许,但跨源读操作默认被禁止。
简单请求与预检请求
CORS(Cross-Origin Resource Sharing)通过 HTTP 头机制突破同源策略的限制,让服务端声明哪些跨源请求是被允许的。
浏览器将跨源请求分为两类:
简单请求需同时满足以下条件:
- 请求方法为
GET、HEAD或POST - 仅允许手动设置的请求头:
Accept、Accept-Language、Content-Language、Content-Type(值仅限于application/x-www-form-urlencoded、multipart/form-data、text/plain)
预检请求:不满足简单请求条件的跨源请求,浏览器会先发送一个 OPTIONS 请求(预检请求)询问服务端是否允许实际请求。
// 客户端代码(会触发预检请求)
fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // 非简单请求的 Content-Type
'X-Custom-Header': 'custom-value' // 自定义头部
},
body: JSON.stringify({ key: 'value' })
})// 服务端响应预检请求的示例
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Custom-Header
Access-Control-Max-Age: 86400Access-Control-* 响应头
| 响应头 | 说明 | 安全要点 |
|---|---|---|
Access-Control-Allow-Origin | 允许的跨源域名 | 避免使用通配符 *(携带凭据时无效) |
Access-Control-Allow-Methods | 允许的 HTTP 方法 | 按需开放,不要全开 |
Access-Control-Allow-Headers | 允许的自定义请求头 | 仅开放业务需要的头部 |
Access-Control-Allow-Credentials | 是否允许携带凭据 | 设为 true 时 ACAO 不能为 * |
Access-Control-Max-Age | 预检结果缓存时长 | 不宜设置过长 |
Access-Control-Expose-Headers | 允许暴露给前端的响应头 | 按需设置 |
withCredentials
跨源请求默认不携带 Cookie 和 HTTP 认证信息。如果需要携带,前端需设置 credentials 选项,同时服务端必须返回明确的 Access-Control-Allow-Origin(不能是 *)且 Access-Control-Allow-Credentials: true。
// 前端携带凭据的跨源请求
fetch('https://api.example.com/user/profile', {
credentials: 'include' // 携带 Cookie
})// 服务端必须返回
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: trueCORS 配置安全误区
误区一:将 Access-Control-Allow-Origin 设为 *
通配符意味着任意网站都可以跨域访问你的接口。如果接口返回了敏感数据,任何恶意站点都可以通过 AJAX 获取。生产环境应明确指定允许的源域名。
误区二:反射请求源而不做校验
// ❌ 危险做法:直接反射 Origin 头
const origin = request.headers.origin;
response.setHeader('Access-Control-Allow-Origin', origin);
response.setHeader('Access-Control-Allow-Credentials', 'true');攻击者可以构造一个恶意页面,通过设置自定义 Origin 来绕过限制。应该维护一个白名单,只允许可信域名。
误区三:过度开放的预检响应
预检请求本身不携带 Cookie,因此有些服务端对预检请求不加限制地返回 Access-Control-Allow-Origin: *,但这可能导致攻击者利用预检探测接口存在性。
postMessage 安全
window.postMessage 是实现跨文档通信的常用 API,特别在 iframe 嵌入和微前端场景中被广泛使用。但若使用不当,会引入严重的安全风险。
targetOrigin 校验
postMessage 的第二个参数 targetOrigin 必须明确指定目标窗口的源,绝不能使用通配符 *。
// ❌ 危险:未指定 targetOrigin
iframe.contentWindow.postMessage(userData, '*');
// ✅ 安全:明确指定目标源
iframe.contentWindow.postMessage(userData, 'https://trusted-parent.com');当 targetOrigin 为 * 时,消息可能被任意恶意窗口接收,导致敏感数据泄露。
message 来源验证
接收消息时,必须校验 event.origin,确保消息来自可信源。
// ❌ 危险:未校验来源
window.addEventListener('message', (event) => {
// 直接信任所有来源的消息
document.getElementById('content').innerHTML = event.data;
});
// ✅ 安全:校验来源
const ALLOWED_ORIGINS = ['https://trusted-parent.com', 'https://trusted-child.com'];
window.addEventListener('message', (event) => {
// 1. 校验来源
if (!ALLOWED_ORIGINS.includes(event.origin)) {
console.warn('来自未知来源的消息已忽略:', event.origin);
return;
}
// 2. 校验数据结构
if (typeof event.data !== 'object' || !event.data.type) {
return;
}
// 3. 安全处理数据
processMessage(event.data);
});避免 XSS 注入
通过 postMessage 传递的数据如果直接被注入 DOM,可能造成 XSS 攻击。
// ❌ 危险:将消息数据直接插入 DOM
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted.com') return;
document.getElementById('output').innerHTML = event.data; // XSS!
});
// ✅ 安全:使用 textContent 而非 innerHTML
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted.com') return;
document.getElementById('output').textContent = event.data;
});
// ✅ 安全:如果必须插入 HTML,需做转义处理
function sanitizeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted.com') return;
document.getElementById('output').innerHTML = sanitizeHTML(event.data);
});安全使用 postMessage 的四项原则:
postMessage时始终指定精确的targetOrigin- 接收消息时始终校验
event.origin - 验证消息的数据结构是否符合预期
- 不要直接将消息数据插入 DOM,或使用安全的插入方式
Clickjacking 点击劫持
点击劫持(Clickjacking)是一种通过透明 iframe 覆盖在合法页面上,诱导用户点击隐藏按钮的攻击技术。攻击者通过伪造的界面诱骗用户执行非预期的操作。
X-Frame-Options
X-Frame-Options 是 HTTP 响应头,控制页面是否允许被嵌入 iframe。
// 禁止所有域名嵌入
X-Frame-Options: DENY
// 仅允许同源域名嵌入
X-Frame-Options: SAMEORIGIN// Express 中设置 X-Frame-Options
const express = require('express');
const app = express();
app.use((req, res, next) => {
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
next();
});CSP frame-ancestors
Content-Security-Policy 的 frame-ancestors 指令是比 X-Frame-Options 更现代的防御方案,支持更细粒度的控制。
// 禁止被嵌入任何页面
Content-Security-Policy: frame-ancestors 'none'
// 仅允许同源嵌入
Content-Security-Policy: frame-ancestors 'self'
// 允许指定域名嵌入
Content-Security-Policy: frame-ancestors https://trusted.com https://app.example.com// Nginx 配置示例
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted.example.com";Framebusting 脚本
作为补充手段,前端可以使用 JavaScript 脚本防止页面被嵌入 iframe(称 Frame Busting 或 Frame Breaking)。
<!--
Framebusting 脚本:检测页面是否在 iframe 中加载
若是则跳出 iframe
-->
<script>
(function() {
if (window.self !== window.top) {
// 方法一:直接跳转
window.top.location = window.self.location;
// 方法二:不可跳转时(某些浏览器沙箱限制)
// 显示警告内容覆盖页面
}
})();
</script>高级 Framebusting 的局限性: 攻击者可以通过以下方式绕过简单的 framebusting:
<!-- 攻击者可能使用的绕过手段 -->
<iframe src="https://target.com" sandbox="allow-scripts"></iframe>sandbox 属性如果包含 allow-top-navigation,则 iframe 中的页面无法跳出。因此 framebusting 只能作为补充防御,不能替代服务端响应头。
双重防御方案
推荐同时使用服务端响应头和前端 framebusting 脚本,构建双重防御。
// 服务端响应头
X-Frame-Options: SAMEORIGIN
Content-Security-Policy: frame-ancestors 'self'<!-- 前端 Framebusting 作为兜底 -->
<script>
(function() {
if (window.top !== window.self) {
window.top.location.replace(window.self.location.href);
}
})();
</script>前端加密误区
许多前端开发者误以为在浏览器端进行加密就能保证数据传输的安全,这是一个常见且危险的认知误区。
前端加密无法替代 HTTPS
核心原则:前端加密不能替代 HTTPS。
// ❌ 错误理解:以为前端加密了就不需要 HTTPS
async function sendPassword() {
const password = document.getElementById('password').value;
// 前端用 RSA 公钥加密密码
const encrypted = await encryptWithPublicKey(password, publicKey);
// 通过 HTTP 发送加密数据
fetch('http://example.com/api/login', { // HTTP!不是 HTTPS!
method: 'POST',
body: JSON.stringify({ encrypted })
});
}即使数据在前端加密,如果通过 HTTP 传输,攻击者仍然可以:
- 直接窃取加密后的数据(中间人攻击)
- 替换前端页面或 JavaScript 代码本身(因为页面也是通过 HTTP 加载的)
- 替换 RSA 公钥为攻击者的公钥
正确做法:始终使用 HTTPS,前端加密只能作为额外的一层保护,而非 HTTPS 的替代品。
前端密钥泄露
前端代码中的所有密钥都是公开的,因为浏览器中的代码对用户完全可见。
// ❌ 危险:将密钥硬编码在前端
const API_SECRET_KEY = 'sk_live_xxxxxxxxxxxxx'; // 一经发布即泄露!
const ENCRYPTION_KEY = 'aes-256-key-1234567890abcdef';
function encryptData(data) {
// 使用前端密钥加密
return CryptoJS.AES.encrypt(data, ENCRYPTION_KEY).toString();
}打开浏览器开发者工具 → Sources 面板,任何人都可以查看 JavaScript 源码、断点调试、修改变量值。前端不存在真正的秘密。
真正的加密应该在服务端
需要加密保护的逻辑应该放在服务端执行:
// ✅ 正确:前端仅做传输,服务端处理加密
// 前端
async function submitSensitiveData(data) {
const response = await fetch('/api/submit-sensitive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}// 服务端(Node.js 示例)
const crypto = require('crypto');
// 加密密钥存储在服务端环境变量中
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY;
function encryptSensitiveData(data) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return { iv: iv.toString('hex'), encrypted: encrypted.toString('hex'), authTag: authTag.toString('hex') };
}前端加密的正确适用场景:
| 场景 | 建议 |
|---|---|
| 表单密码传输 | 使用 HTTPS,可额外加一层 RSA 加密作为防御纵深 |
| 本地缓存敏感数据 | 使用 Web Crypto API 加密,但需接受密钥暴露的风险 |
| 用户间端到端加密 | 密钥在客户端生成,服务端不持有私钥(如即时通讯端到端加密) |
| 支付卡号等合规场景 | 遵循 PCI-DSS 规范,使用服务端加密存储 |
前端供应链安全
CDN 引入第三方库风险
从 CDN 加载第三方库存在被篡改的风险。如果 CDN 被攻破或库的发布者账号被盗,恶意代码会被推送到所有使用该 CDN 的网站。
<!-- ❌ 危险:直接从 CDN 加载,无完整性校验 -->
<script src="https://cdn.example.com/jquery/3.6.0/jquery.min.js"></script>
<!-- ✅ 安全:使用 SRI 完整性校验 -->
<script
src="https://cdn.example.com/jquery/3.6.0/jquery.min.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"
></script>SRI Subresource Integrity
SRI(Subresource Integrity)通过哈希值校验确保加载的第三方资源未被篡改。浏览器在下载资源后计算其哈希值,与 integrity 属性中的值比对,若不匹配则拒绝执行。
<!-- 为多个第三方资源配置 SRI -->
<link
rel="stylesheet"
href="https://cdn.example.com/bootstrap/5.3.0/css/bootstrap.min.css"
integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm"
crossorigin="anonymous"
/>
<script
src="https://cdn.example.com/vue/3.3.0/vue.global.prod.js"
integrity="sha384-ABC123DEF456GHI789JKL012MNO345PQR678STU890VWX"
crossorigin="anonymous"
></script>生成 SRI 哈希值:
# 使用 openssl 生成 sha384 哈希值
openssl dgst -sha384 -binary jquery.min.js | openssl base64 -A
# 或使用 node.js
node -e "const crypto = require('crypto'); const fs = require('fs'); const hash = crypto.createHash('sha384'); hash.update(fs.readFileSync('jquery.min.js')); console.log(hash.digest('base64'));"npm 依赖审计
npm 生态中存在大量恶意包和已知漏洞的依赖包。
# 审计项目依赖中的已知漏洞
npm audit
# 查看漏洞详情
npm audit --audit-level=high
# 修复可自动修复的漏洞
npm audit fix
# 仅修复补丁版本
npm audit fix --target=patchpackage.json 安全实践:
{
"name": "secure-app",
"private": true,
"scripts": {
"preinstall": "npx npm-audit-resolver",
"audit": "npm audit --audit-level=high"
},
"dependencies": {
"express": "^4.18.0"
},
"overrides": {
"semver": "7.5.4",
"minimatch": "9.0.3"
}
}供应链安全最佳实践:
- 使用
package-lock.json或yarn.lock锁定依赖版本 - 配置 CI/CD 流程中的
npm audit检查,高危漏洞阻断构建 - 定期审查和更新依赖,避免使用不再维护的库
- 使用
npm shrinkwrap锁定依赖树 - 使用
overrides或resolutions字段覆盖传递依赖的版本 - 对于关键项目,考虑自建私有 npm 仓库,对依赖包进行安全审核后再引入
前端日志与错误信息泄露
前端日志在开发调试中不可或缺,但在生产环境中过度或不当的日志输出可能导致敏感信息泄露。
常见的日志泄露场景
// ❌ 危险:在生产环境打印敏感数据
console.log('用户登录信息:', {
username: 'admin',
token: 'eyJhbGciOiJIUzI1NiIs...', // JWT Token 泄露
apiKey: 'sk_live_xxxxxxxxxx', // API 密钥泄露
userInfo: { /* 包含身份证、手机号等 */ }
});
// ❌ 危险:将服务端错误详情直接展示给用户
fetch('/api/data')
.then(res => res.json())
.catch(err => {
// 将原始错误信息直接显示在界面上,可能暴露内部路径、SQL 等
document.getElementById('error').textContent = err.message;
// err.message 可能类似:
// "Failed to connect to database: postgresql://internal:password@10.0.0.1:5432/prod"
});安全日志实践
// ✅ 安全:区分开发/生产环境日志
const isProduction = process.env.NODE_ENV === 'production';
const logger = {
debug: (...args) => {
if (!isProduction) {
console.debug('[DEBUG]', ...args);
}
},
info: (...args) => {
// 生产环境可以保留 info 级别日志,但要过滤敏感字段
const sanitized = args.map(arg => sanitizeSensitiveFields(arg));
console.info('[INFO]', ...sanitized);
},
error: (...args) => {
// 生产环境记录错误但不暴露原始错误详情给用户
console.error('[ERROR]', ...args);
// 同时将详细错误发送到日志服务(不包含敏感信息)
reportErrorToServer(args[0]);
}
};
// 敏感字段过滤
function sanitizeSensitiveFields(data) {
if (typeof data !== 'object' || data === null) return data;
const sensitiveKeys = ['token', 'password', 'secret', 'apiKey', 'authorization'];
const sanitized = { ...data };
for (const key of sensitiveKeys) {
if (key in sanitized) {
sanitized[key] = '***REDACTED***';
}
}
return sanitized;
}
// 使用示例
logger.debug('当前组件状态:', componentState); // 仅在开发环境输出
logger.error('API 请求失败:', { status: 500, message: 'Internal Server Error' }); // 生产环境记录但不包含敏感细节错误边界处理
在 React 或 Vue 等前端框架中,可以通过错误边界捕获渲染阶段的异常,避免完整的调用栈暴露给用户。
// React 错误边界示例
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, errorId: null };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
// 将错误详情发送到服务端日志系统,不展示给用户
logErrorToServer({
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
});
// 生成一个错误追踪 ID 供后续排查
this.setState({ errorId: generateErrorId() });
}
render() {
if (this.state.hasError) {
return (
<div className="error-page">
<h2>页面出现异常</h2>
{/* 只展示友好的错误提示,不包含技术细节 */}
<p>很抱歉,页面暂时无法正常显示。请联系技术支持,并提供以下追踪编号:</p>
<code>{this.state.errorId}</code>
{/* 不要这样做:<pre>{this.state.error.stack}</pre> */}
</div>
);
}
return this.props.children;
}
}错误监控平台的安全使用
// ✅ 安全:使用 Sentry 等监控平台时配置去除敏感字段
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
// 去除请求体中的敏感字段
beforeSend(event) {
if (event.request && event.request.data) {
const sensitiveFields = ['password', 'token', 'creditCard', 'ssn'];
sensitiveFields.forEach(field => {
if (event.request.data[field]) {
event.request.data[field] = '***';
}
});
}
return event;
},
// 不捕获特定域名的错误(如第三方 CDN 资源)
denyUrls: [/https:\/\/cdn\.example\.com/],
// 设置采样率
sampleRate: 0.5
});前端日志安全原则:
- 生产环境禁止
console.log、console.debug、console.trace输出详细信息 - 全局捕获的错误信息在上报到日志平台前需脱敏
- 用户看到的错误提示应友好且不包含技术细节
- 使用构建工具移除生产环境的日志代码(如 Terser 的
drop_console选项) - 对错误监控平台上报的数据进行过滤,排除敏感字段
总结
前端安全是一个系统工程,涉及浏览器安全机制、网络通信协议、代码供应链管理等多个层面。本文重点讨论了以下六个方面:
| 安全领域 | 核心风险 | 关键防御措施 |
|---|---|---|
| CORS | 跨域数据泄露 | 明确 Origin 白名单,避免通配符,正确配置凭据 |
| postMessage | 跨文档消息被篡改或窃听 | 校验 targetOrigin 和 event.origin,避免 XSS 注入 |
| Clickjacking | 用户被诱导点击 | 响应头 + CSP + framebusting 双重防御 |
| 前端加密 | 密钥暴露、加密被绕过 | 始终使用 HTTPS,核心加密逻辑放在服务端 |
| 供应链安全 | 第三方库被篡改 | SRI 完整性校验,npm audit 依赖审计 |
| 日志泄露 | 敏感信息被窃取 | 生产环境脱敏,错误边界隔离,监控平台黑名单 |
安全不是一次性工作,而是需要融入日常开发流程的持续实践。建议团队在代码审查中加入安全检查项,并使用自动化工具(如 ESLint 安全规则、依赖扫描工具)辅助发现潜在风险。