Vue 基础体系 · 第 17/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 前端安全:XSS、URL、Token、CSRF、依赖和富文本边界

Vue 应用的安全问题通常不来自某一个“危险 API”,而来自数据跨越边界时没有改变处理方式:

  • 用户输入进入 HTML、JavaScript 或 CSS 上下文,可能形成 XSS;
  • 外部字符串被当成 URL、跳转地址或资源地址,可能形成协议注入、开放重定向或敏感信息泄漏;
  • Token 被放入可被脚本读取的位置,可能被 XSS 窃取;
  • 身份凭证由浏览器自动携带时,可能被跨站请求伪造;
  • 依赖包、构建脚本和生产配置扩大了应用的攻击面;
  • 富文本既不是普通字符串,也不是可以直接交给 v-html 的“安全 HTML”。

因此,安全边界应当按照“数据来自哪里、将被解释为什么、由谁自动携带”来分析,而不是简单记忆某个 API 是否安全。


一、先建立威胁模型:数据会被谁解释

安全分析至少要区分四类对象:

  1. 数据来源:用户输入、URL 参数、后端响应、第三方接口、环境变量、依赖包。
  2. 解释器:HTML 解析器、JavaScript 引擎、URL 解析器、CSS 解析器、浏览器 Cookie 机制。
  3. 凭证携带方式:显式放入请求头,还是浏览器自动附带 Cookie。
  4. 信任边界:前端代码、后端服务、同源页面、跨站页面、第三方脚本之间并不天然互信。

例如,下面三个字符串看起来都只是字符串,但风险完全不同:

const value = '<img src=x onerror=alert(1)>'

element.textContent = value // 作为文本显示
element.innerHTML = value   // 作为 HTML 解析
window.location.href = value // 作为 URL 解析

第一种不会创建元素;第二种会触发 HTML 解析;第三种可能触发跳转、协议处理或敏感信息泄露。安全策略必须与“最终解释上下文”匹配。


二、XSS:从数据变成可执行代码的过程

2.1 XSS 的定义和成立条件

XSS(Cross-Site Scripting,跨站脚本攻击)是指攻击者控制的数据进入页面后,被浏览器当成脚本或可触发脚本的 HTML 结构执行。

一个简化的成立条件是:

XSS=攻击者可控输入进入可执行上下文缺少正确的上下文防护\text{XSS} = \text{攻击者可控输入} \land \text{进入可执行上下文} \land \text{缺少正确的上下文防护}

三个条件缺一不可:

  • 只有输入可控,但始终以纯文本显示,不足以形成 XSS;
  • 只有 HTML 渲染能力,但输入经过正确的白名单清洗,也不一定形成 XSS;
  • 只做字符串替换而没有考虑 HTML、URL、CSS、JavaScript 的不同语法,通常不能证明安全。

常见类型包括:

  • 存储型 XSS:恶意内容保存到数据库,其他用户查看时执行;
  • 反射型 XSS:恶意内容通过 URL 参数等进入响应或页面;
  • DOM 型 XSS:前端 JavaScript 直接把不可信数据写入危险 DOM API。

2.2 Vue 模板默认做了什么

Vue 的模板插值和普通属性绑定会进行 HTML 转义:

<script setup lang="ts">
import { ref } from 'vue'

const comment = ref('<img src=x onerror=alert(1)>')
</script>

<template>
  <p>{{ comment }}</p>
  <div :title="comment">悬停查看</div>
</template>

浏览器看到的结果是文本内容,不会把 <img> 创建成元素。Vue 文档所描述的自动转义是重要的默认防线,但它不是“所有输入都安全”的证明,因为开发者仍然可以主动使用绕过边界的 API。

最典型的绕过是:

<template>
  <div v-html="comment"></div>
</template>

v-html 的含义不是“把字符串显示出来”,而是“把字符串交给元素的 innerHTML 解析”。如果 comment 来自用户、CMS、评论接口或不完全可信的第三方服务,就必须先经过 HTML 消毒,而不能依赖 Vue 自动保护。

以下 API 也需要按危险写入处理:

element.innerHTML = value
element.outerHTML = value
element.insertAdjacentHTML('beforeend', value)

以及把不可信字符串交给可执行代码的 API:

eval(value)
new Function(value)
setTimeout(value, 1000) // 传入字符串时会按代码执行

在 Vue 中,:is、动态组件、事件绑定等功能本身不是任意代码执行入口,但如果开发者把不可信字符串转换成组件名、模块路径或模板代码,就会把数据提升为代码或组件选择逻辑,必须重新审查。

2.3 一个具体的 XSS 传播路径

假设评论接口返回:

{
  "content": "<img src=x onerror=\"fetch('/api/me').then(r => r.text()).then(alert)\">"
}

如果组件这样写:

<template>
  <article v-html="comment.content"></article>
</template>

传播过程是:

  1. 攻击者提交包含事件处理器的字符串;
  2. 服务端保存字符串并在接口中返回;
  3. Vue 执行 v-html
  4. 浏览器将字符串解析为 HTML;
  5. onerror 事件处理器执行;
  6. 当前页面中的脚本权限被滥用,例如读取可访问的数据、调用接口或修改页面。

这里不需要攻击者直接窃取 Cookie。即使 Cookie 设置了 HttpOnly,运行在页面上下文中的恶意脚本仍可能以当前用户身份调用接口、读取接口响应或修改账户设置。

2.4 正确的富文本处理:允许 HTML 不等于信任 HTML

如果业务必须支持加粗、链接、列表等富文本,应使用明确的清洗流程:

// npm install dompurify
import DOMPurify from 'dompurify'

const dirtyHtml = '<p>Hello</p><img src=x onerror=alert(1)>'

const cleanHtml = DOMPurify.sanitize(dirtyHtml, {
  ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'br'],
  ALLOWED_ATTR: ['href', 'title', 'target', 'rel']
})

组件中:

<script setup lang="ts">
import DOMPurify from 'dompurify'
import { computed } from 'vue'

const props = defineProps<{
  html: string
}>()

const safeHtml = computed(() =>
  DOMPurify.sanitize(props.html, {
    ALLOWED_TAGS: ['p', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'br'],
    ALLOWED_ATTR: ['href', 'title', 'target', 'rel']
  })
)
</script>

<template>
  <div v-html="safeHtml"></div>
</template>

这段代码成立的前提是:

  • DOMPurify 版本受控并及时更新;
  • 清洗配置与业务允许的标签、属性一致;
  • 清洗后的结果只进入预期的 HTML 上下文;
  • 服务端也执行清洗或验证,不能只相信浏览器端;
  • 不把“已经清洗过”的字符串再次拼接到 JavaScript、CSS 或 URL 上。

客户端清洗主要保护当前浏览器;服务端清洗可以防止恶意内容污染数据库、邮件、管理后台、SSR 页面和其他客户端。两者承担的边界不同,不能用前端清洗替代服务端数据治理。


三、URL:字符串不是地址,地址也不只是路径

3.1 URL 中真正危险的部分

URL(Uniform Resource Locator,统一资源定位符)至少包含协议、主机、端口、路径、查询参数和片段:

https://example.com:443/products?id=1#detail
\___/   \___________/ \_/ \____/ \_____/
协议       主机       端口  路径    查询/片段

不同位置承担不同含义:

  • https:// 决定访问协议;
  • 主机决定请求发送到哪里;
  • 查询参数可能改变服务端行为;
  • 片段通常只在浏览器端使用,不会发送到 HTTP 服务端;
  • javascript:data: 等协议可能改变字符串的执行或资源加载行为。

因此,以下写法不能因为使用了 encodeURIComponent 就自动安全:

const url = `/search?q=${encodeURIComponent(userInput)}`

它只编码了查询参数值,不能证明整个 URL 的协议、主机和路径可信。

3.2 参数编码与 URL 构造

查询参数应使用 URLURLSearchParams 构造:

const url = new URL('/search', window.location.origin)
url.searchParams.set('q', userInput)

console.log(url.toString())

这解决的是参数边界问题:用户输入会被编码成参数值,而不是被误认为新的 &# 或其他 URL 结构。

但它不解决“用户输入是否应该作为 URL”这一更高层问题。例如:

const target = new URL(userInput, window.location.origin)

如果 userInput 是:

https://evil.example/phishing

构造结果就确实指向外部站点。语法正确不等于业务允许。

3.3 外链和协议白名单

当业务允许用户提交链接时,应明确允许的协议:

export function safeHttpUrl(raw: string): string | null {
  try {
    const url = new URL(raw, window.location.origin)

    if (url.protocol !== 'http:' && url.protocol !== 'https:') {
      return null
    }

    return url.href
  } catch {
    return null
  }
}

使用:

<script setup lang="ts">
import { computed } from 'vue'
import { safeHttpUrl } from './safeHttpUrl'

const props = defineProps<{ href: string }>()

const safeHref = computed(() => safeHttpUrl(props.href))
</script>

<template>
  <a
    v-if="safeHref"
    :href="safeHref"
    target="_blank"
    rel="noopener noreferrer"
  >
    打开链接
  </a>
  <span v-else>无效链接</span>
</template>

rel="noopener noreferrer" 的作用需要区分:

  • noopener 防止新页面通过 window.opener 控制原页面;
  • noreferrer 通常还会抑制 Referer 发送;
  • 它们不能替代协议校验,也不能阻止跳转到钓鱼站点。

对富文本链接也必须检查 href,因为 HTML 清洗中允许 <a> 标签并不代表任意协议都安全。至少要拒绝:

javascript:alert(1)
data:text/html,...
vbscript:...

3.4 路由参数、开放重定向和 SSRF 的边界

Vue Router 的参数会进入页面逻辑:

const route = useRoute()
const id = route.params.id

路由参数不应直接拼接到 HTML、SQL、命令或任意外部跳转中。即使前端使用了:

router.push(`/users/${encodeURIComponent(id)}`)

服务端仍必须验证 id 的格式和权限。

登录后跳转是开放重定向的常见来源:

const redirect = new URLSearchParams(location.search).get('redirect')
location.href = redirect ?? '/'

攻击者可以构造:

/login?redirect=https://evil.example

更安全的做法是只允许站内路径:

export function safeRedirect(raw: string | null): string {
  if (!raw) return '/'

  try {
    const url = new URL(raw, window.location.origin)

    if (url.origin !== window.location.origin) {
      return '/'
    }

    return `${url.pathname}${url.search}${url.hash}`
  } catch {
    return '/'
  }
}

这里的“站内”判定仍应符合业务要求。某些应用有多个可信域名,应使用服务端配置的精确白名单,而不是模糊的字符串匹配,例如不要用 url.hostname.endsWith('example.com') 代替严格规则,因为 evil-example.com 也可能满足错误的判断。

前端 URL 校验不能防止服务端 SSRF。若服务端根据前端提交的 URL 去抓取资源,服务端还必须防止访问内网地址、云元数据地址和重定向后的不可信主机。


四、Token:凭证的能力、生命周期和存储位置

4.1 Token 是什么

Token 是代表某种授权状态的凭证。最常见的是 Bearer Token:

Authorization: Bearer eyJ...

“Bearer”意味着谁持有该 Token,谁通常就能以该身份调用对应接口。因此 Token 泄漏不是普通数据泄漏,而是身份凭证泄漏。

JWT 只是 Token 的一种格式,不等于安全方案。JWT 常见结构是:

Base64URL(header).Base64URL(payload).Base64URL(signature)

其中 header 和 payload 通常只是编码,不是加密。把密码、隐私字段或长期秘密放入 JWT payload,不能依靠 Base64 隐藏。

服务端必须验证:

  • 签名;
  • exp 过期时间;
  • iss 签发者;
  • aud 受众;
  • 算法是否符合服务端允许列表;
  • 用户、权限和资源访问关系。

前端解码 JWT 只能用于显示过期时间等非安全逻辑,不能由前端决定“这个 Token 是否可信”。

4.2 Token 放在哪里

常见位置及边界如下:

位置 JavaScript 可读性 XSS 窃取风险 CSRF 风险
localStorage 可读 通常较低,因为不会自动作为 Cookie 发送
sessionStorage 可读 通常较低
内存变量 当前页面可读 受 XSS 影响,但刷新后消失 通常较低
HttpOnly Cookie JavaScript 不可读 能降低直接窃取 Cookie 的风险 仍可能有 CSRF

HttpOnly 只禁止 JavaScript 读取 Cookie,不会阻止浏览器在请求中自动携带它,也不会阻止 XSS 以当前页面身份发起请求。因此“用了 HttpOnly 就没有 XSS 或 CSRF”是错误的。

一个常见取舍是:

  • 短期 Access Token 放在内存中;
  • Refresh Token 放在 HttpOnly; Secure Cookie;
  • Access Token 过期时通过刷新接口获取新 Token;
  • 刷新接口采用 CSRF 防护,并在服务端轮换 Refresh Token。

这种设计降低了长期凭证被前端脚本直接读取的风险,但增加了刷新、并发和登出处理的复杂度。

4.3 Vue 中的内存 Token 和请求封装

// auth.ts
import { ref } from 'vue'

const accessToken = ref<string | null>(null)

export function setAccessToken(token: string | null) {
  accessToken.value = token
}

export function getAccessToken() {
  return accessToken.value
}

请求封装:

import { getAccessToken } from './auth'

export async function apiFetch(
  input: RequestInfo | URL,
  init: RequestInit = {}
): Promise<Response> {
  const headers = new Headers(init.headers)
  const token = getAccessToken()

  if (token) {
    headers.set('Authorization', `Bearer ${token}`)
  }

  const response = await fetch(input, {
    ...init,
    headers,
    credentials: 'same-origin'
  })

  if (response.status === 401) {
    // 生产代码应进入统一的刷新/登出状态机,
    // 而不是每个组件各自重试。
  }

  return response
}

这里的 401 不是“请求失败后无限重试”的信号。若多个请求同时收到 401,应由认证模块维护一个共享的刷新 Promise:

let refreshPromise: Promise<string | null> | null = null

async function refreshAccessToken(): Promise<string | null> {
  if (!refreshPromise) {
    refreshPromise = fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include'
    })
      .then(async response => {
        if (!response.ok) return null
        const data = await response.json() as { accessToken: string }
        setAccessToken(data.accessToken)
        return data.accessToken
      })
      .finally(() => {
        refreshPromise = null
      })
  }

  return refreshPromise
}

如果不共享刷新过程,三个并发请求可能同时刷新,服务端的 Refresh Token 轮换机制会把其中两个判断为重放,造成“偶发登出”。这与异步请求的竞态问题直接相关:认证刷新也是一种共享状态和并发控制问题。


五、CSRF:浏览器自动携带凭证造成的请求伪造

5.1 CSRF 的成立条件

CSRF(Cross-Site Request Forgery,跨站请求伪造)利用的是浏览器的自动行为。

一个简化条件是:

CSRF=受害者已认证浏览器自动携带凭证服务端仅凭凭证执行状态变更攻击者能诱导请求\text{CSRF} = \text{受害者已认证} \land \text{浏览器自动携带凭证} \land \text{服务端仅凭凭证执行状态变更} \land \text{攻击者能诱导请求}

典型场景:

  1. 用户已登录 bank.example,Cookie 保存在浏览器中;
  2. 用户访问攻击者控制的 evil.example
  3. 恶意页面提交一个表单到 bank.example/transfer
  4. 浏览器自动携带 bank.example 的 Cookie;
  5. 银行服务端若只检查 Cookie,就可能把请求当作用户本人发起。

攻击者通常不需要读取响应。对转账、改邮箱、删数据这类操作,只要请求成功即可。

<form action="https://bank.example/transfer" method="POST">
  <input name="to" value="attacker-account">
  <input name="amount" value="1000">
</form>
<script>
  document.forms[0].submit()
</script>

5.2 Bearer Header 与 Cookie 的区别

若认证凭证必须由 JavaScript 设置为:

Authorization: Bearer ...

普通跨站 HTML 表单不能任意设置这个请求头,因此 CSRF 面通常会降低。但这不是绝对的安全结论:

  • XSS 可以读取内存或 Web Storage 中的 Token;
  • 错误开放的 CORS 可能允许其他站点发送带凭证请求并读取响应;
  • 业务仍可能使用 Cookie 作为其他认证或会话凭证;
  • 服务端不应仅因为请求来自前端就信任它。

如果使用 Cookie 会话,则应采用专门的 CSRF 防护。

5.3 Cookie 属性:SameSite、Secure、HttpOnly

服务端设置 Cookie 时通常需要考虑:

Set-Cookie: session=...; Path=/; Secure; HttpOnly; SameSite=Lax
  • Secure:只通过 HTTPS 发送;
  • HttpOnly:禁止 JavaScript 读取;
  • SameSite=Lax:限制多数跨站请求携带 Cookie;
  • SameSite=Strict:限制更严格,但可能影响从外部站点进入应用的登录体验;
  • SameSite=None:允许跨站携带,必须同时设置 Secure

SameSite 是浏览器行为限制,不应替代应用层 CSRF 校验。浏览器版本、跨站流程、嵌入场景和业务域名结构都会影响实际行为。

5.4 CSRF Token 的校验流程

常见方案是服务端为会话生成随机 CSRF Token,并要求状态变更请求携带它:

POST /api/profile
Cookie: session=...
X-CSRF-Token: random-token
Content-Type: application/json

客户端:

async function updateProfile(profile: { nickname: string }) {
  const csrfToken = document
    .querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
    ?.content

  if (!csrfToken) {
    throw new Error('缺少 CSRF Token')
  }

  const response = await fetch('/api/profile', {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken
    },
    body: JSON.stringify(profile)
  })

  if (!response.ok) {
    throw new Error(`更新失败:${response.status}`)
  }
}

服务端必须比较 Token,而不是只检查请求头是否存在。Token 应具有不可预测性,并与会话绑定,或者采用经过严格设计的双重提交 Cookie 方案。

CSRF 防护通常只应用于会改变状态的请求:

  • POST
  • PUT
  • PATCH
  • DELETE

GET 应保持幂等和无副作用。把删除、转账等操作设计成 GET,会让预取、爬虫、图片加载和恶意链接都可能触发状态变更。

5.5 CORS 不是 CSRF 防护

CORS(跨源资源共享)主要控制“其他源的脚本能否读取响应”,不是控制“请求是否能发出”。

例如,跨站表单可以发出某些简单请求,即使响应不能被攻击者脚本读取。若该请求已经删除了数据,CORS 并不能挽回结果。

服务端应:

  • 对允许的 Origin 使用精确白名单;
  • 不要对带凭证请求返回 Access-Control-Allow-Origin: *
  • 仅在需要时允许 Access-Control-Allow-Credentials: true
  • 仍然执行 Cookie、CSRF Token、权限和业务校验。

六、前端中的完整认证请求流

下面是一个典型的请求状态机:

sequenceDiagram
    participant V as Vue组件
    participant A as API封装
    participant S as 服务端
    participant B as 浏览器Cookie

    V->>A: 请求业务数据
    A->>S: Authorization: Access Token
    S-->>A: 200 数据
    A-->>V: 更新成功状态

    V->>A: 另一个请求
    A->>S: 过期 Access Token
    S-->>A: 401
    A->>S: POST /auth/refresh
    B-->>S: 自动携带 HttpOnly Refresh Cookie
    S-->>A: 新 Access Token
    A->>S: 重放原请求
    S-->>A: 200 数据
    A-->>V: 更新成功状态

关键故障路径不能省略:

  • Refresh Cookie 过期:清除内存 Token,跳转登录;
  • 刷新接口返回 403:通常表示 CSRF 校验失败,不应无限重试;
  • 网络错误:保留业务错误状态,不能误判为登录失效;
  • 多请求同时 401:共享刷新 Promise;
  • 原请求不是幂等操作:重放前必须确认服务端是否已经执行过,避免重复扣款或重复提交;
  • 页面刷新:内存 Access Token 消失,需要通过刷新接口恢复会话。

这也是“请求状态”和“认证状态”必须分离的原因。loadingerrorunauthorizedforbiddenconflict 不是同一个状态。


七、依赖安全:构建过程也是运行时供应链

7.1 依赖为什么会成为攻击面

前端依赖会进入以下位置:

  • 开发机和 CI 的安装过程;
  • Vite 构建过程;
  • 打包产物;
  • 浏览器运行时;
  • 可能被执行的 npm lifecycle script。

因此,恶意依赖不一定需要等到用户打开页面才攻击。它可以在安装或构建阶段读取环境变量、源码、凭证和 CI 文件。

Vue 项目中的风险来源包括:

  • 拼写相似的恶意包;
  • 被接管的上游包;
  • 过期依赖中的已知漏洞;
  • 构建插件读取源码或环境变量;
  • postinstall 等生命周期脚本;
  • 过度宽松的版本范围导致不可预期升级。

7.2 锁文件、审计和升级

CI 中应使用锁文件保证依赖解析结果稳定:

npm ci
npm audit --omit=dev
npm outdated

命令含义不同:

  • npm ci 根据锁文件安装,若 package.json 与锁文件不一致会失败,适合 CI;
  • npm audit --omit=dev 检查生产依赖中的已知漏洞,但结果依赖漏洞数据库,不能证明绝对安全;
  • npm outdated 展示可升级版本,不代表升级一定兼容。

不要把:

npm audit fix --force

当作无风险修复。它可能升级主版本,改变运行行为。正确流程通常是:

  1. 查看漏洞受影响的直接依赖和传递依赖;
  2. 确认漏洞是否进入生产包或仅存在于开发工具;
  3. 阅读修复版本的变更说明;
  4. 在分支中升级;
  5. 执行类型检查、单元测试、构建和关键流程验证;
  6. 再合并并记录回滚方式。

package-lock.jsonpnpm-lock.yamlyarn.lock 应纳入版本控制。锁文件防止“今天安装到 A、明天安装到 B”,但不能阻止锁定版本本身是恶意或有漏洞的。

7.3 安装脚本和环境变量

对不需要安装脚本的项目,可以在受控环境中评估:

npm ci --ignore-scripts

这不是普遍可用的修复,因为某些合法依赖确实需要构建脚本。它的价值在于帮助判断“安装脚本是否是必要的攻击面”,而不是盲目关闭所有脚本。

Vite 中以 VITE_ 开头的环境变量会暴露给客户端代码:

VITE_API_BASE=https://api.example.com

可以公开 API 地址、功能开关等配置,但不能放:

VITE_DATABASE_PASSWORD=...
VITE_PRIVATE_SIGNING_KEY=...
VITE_ADMIN_SECRET=...

构建产物最终会发送给用户,任何进入前端 JavaScript 的值都不能被视为秘密。生产部署应通过后端、网关或安全的运行时配置机制管理秘密,而不是把秘密编译进静态资源。


八、内容安全策略:降低 XSS 的爆炸半径

CSP(Content Security Policy,内容安全策略)通过响应头限制脚本、样式、图片、连接等资源来源。例如:

Content-Security-Policy:
  default-src 'self';
  script-src 'self';
  style-src 'self' 'unsafe-inline';
  img-src 'self' https: data:;
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

CSP 是浏览器的额外约束,不是输入清洗的替代品:

  • 它不能修复后端存储恶意 HTML;
  • 配置过宽会削弱效果;
  • unsafe-inline 会允许更多内联样式或脚本能力,具体影响取决于策略;
  • 第三方脚本一旦被允许,第三方脚本本身就成为信任边界的一部分。

启用前可以使用报告模式:

Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint

先观察页面资源和内联代码,再逐步收紧策略。生产发布时应确认 CSP、静态资源域名、API 域名和灰度环境配置一致;否则可能出现“正式环境页面白屏、灰度环境正常”的交付故障。


九、富文本的完整边界:解析器不止 HTML

9.1 Markdown 也不是纯文本

Markdown 输入可能生成:

[点击这里](javascript:alert(1))

即使 Markdown 解析器本身没有执行 JavaScript,生成的 <a href="..."> 仍可能形成危险 URL。因此富文本处理至少包含两步:

  1. 解析 Markdown 或 HTML;
  2. 对输出 HTML 的标签、属性和协议进行白名单清洗。

不要使用正则表达式试图完整解析 HTML。HTML 具有嵌套、实体、属性引号、命名空间和浏览器容错规则,简单替换很容易被绕过。

9.2 SVG、样式和资源加载

富文本中以下内容需要额外审查:

  • SVG 标签和 SVG 外链;
  • style 属性和 CSS URL;
  • srcsrcsetposter 等资源属性;
  • iframeobjectembed
  • formaction
  • target="_blank" 及其 rel
  • 自定义元素和命名空间。

例如,允许图片并不等于允许任意资源地址:

<img src="https://tracker.example/pixel">

它可能造成隐私泄漏、用户行为追踪或内网资源探测。是否允许外链图片属于业务和隐私策略,而不是单纯的 XSS 判断。

9.3 渲染富文本的组件边界

可以把富文本封装为单一组件:

<!-- SafeRichText.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'

const props = defineProps<{
  value: string
}>()

const sanitized = computed(() => {
  return DOMPurify.sanitize(props.value, {
    ALLOWED_TAGS: [
      'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
      'blockquote', 'code', 'pre', 'a'
    ],
    ALLOWED_ATTR: ['href', 'title', 'target', 'rel']
  })
})
</script>

<template>
  <div class="rich-text" v-html="sanitized"></div>
</template>

业务代码不再到处直接写 v-html

<SafeRichText :value="article.content" />

这不是形式上的封装,而是把“进入 HTML 解释器前必须清洗”的约束集中到一个可审计位置。若某个页面需要不同的标签集合,应明确传入策略,而不是让每个调用者自行拼接配置。


十、常见错误及其真实失败表现

错误一:把 {{ value }} 安全推广到 v-html

<div>{{ value }}</div>   <!-- Vue 转义 -->
<div v-html="value"></div> <!-- 交给 HTML 解析器 -->

失败表现是评论区、文章页或后台预览出现异常元素、跳转、外链图片请求,甚至执行事件处理器。排查时应搜索项目中的:

grep -R "v-html\|innerHTML\|insertAdjacentHTML" src

然后逐个确认输入来源、清洗位置和最终使用上下文。

错误二:只过滤 <script>

攻击不一定需要 <script> 标签:

<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<a href="javascript:alert(1)">link</a>

浏览器的事件属性、危险协议、SVG 和 CSS 都可能成为执行或外带数据的路径。应使用经过维护的、上下文感知的清洗器,并限制允许集合。

错误三:把 Token 放进 localStorage 后认为“不会 CSRF”

这可能减少浏览器自动带凭证造成的 CSRF,但 XSS 仍能:

const token = localStorage.getItem('access_token')
fetch('/api/account', {
  headers: { Authorization: `Bearer ${token}` }
})

攻击脚本不一定需要把 Token 传走;它可以直接在当前页面调用接口并读取同源响应。

错误四:把 HttpOnly 当成 XSS 的完整解决方案

HttpOnly 阻止的是:

document.cookie

但不能阻止:

fetch('/api/delete-account', { method: 'POST' })

恶意脚本仍可利用页面上下文发送请求。它降低的是凭证“直接读取和复制”的风险,不是 XSS 的所有影响。

错误五:把 CORS 当作 CSRF Token

CORS 可能阻止攻击者读取响应,但请求本身仍可能发送。状态修改接口仍需检查 CSRF Token、SameSite、Origin/Referer、权限和业务参数。

错误六:用字符串拼接判断 URL 是否可信

这些检查都不可靠:

raw.startsWith('https://example.com')
raw.includes('example.com')
raw.endsWith('.example.com')

应使用 new URL() 解析后比较 originprotocolhostname 等结构化字段,并处理异常和重定向策略。


十一、诊断方法:从数据流而不是报错文本开始

11.1 XSS 排查

按以下路径追踪:

输入来源
  -> 状态管理 / API 响应
  -> 模板或 DOM API
  -> 浏览器解释上下文

重点搜索:

grep -R "v-html\|innerHTML\|outerHTML\|insertAdjacentHTML" src
grep -R "location.href\|window.open\|router.push" src

浏览器 DevTools 中可检查:

  • 元素是否被当成节点插入;
  • 是否出现不预期的事件属性;
  • Network 是否请求了未知外链;
  • CSP 是否报告违规;
  • Source 面板中恶意值从哪里进入 DOM。

测试时使用无害标记和受控环境,不要在生产页面直接执行攻击载荷。

11.2 Token 和 CSRF 排查

在 Network 面板确认:

  • Access Token 是否出现在 URL 查询参数中;
  • 是否被写入日志、错误上报或埋点;
  • Cookie 是否带有 SecureHttpOnlySameSite
  • 状态变更请求是否带 CSRF Token;
  • 多个 401 是否只触发一次刷新;
  • 刷新失败后是否停止重试并进入登出状态。

不要把 Token 放在 URL 中:

https://example.com/callback?token=secret

URL 可能进入浏览器历史、代理日志、Referer、监控系统和服务器访问日志。

11.3 依赖排查

在 CI 中保留:

npm ci
npm audit --omit=dev
npm run type-check
npm run build

具体脚本名称由项目配置决定,不能假定所有 Vue 项目都有 type-check。同时应审查:

  • 锁文件变更;
  • 新增包的发布者、维护状态和安装脚本;
  • 构建产物是否出现不应公开的环境变量;
  • 依赖升级后的关键用户流程;
  • 生产构建和回滚产物是否可复现。

十二、把安全约束放进组件和交付流程

安全不是只存在于组件代码中。Vue 生产交付还涉及:

  • 环境变量是否把秘密打进静态资源;
  • CDN 和缓存是否错误缓存了带用户数据的响应;
  • 灰度环境是否使用了不同的 Cookie 域、CORS 白名单或 CSP;
  • 回滚版本是否仍依赖已经撤下的外部资源;
  • API 版本变化是否让旧前端错误重试;
  • 异步请求取消后是否仍然更新了认证或用户状态。

例如,分页请求可以被取消,但取消不应被当作认证失败:

const controller = new AbortController()

try {
  const response = await fetch('/api/items?page=2', {
    signal: controller.signal
  })

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }
} catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    // 用户主动切换分页,属于取消,不应显示“系统错误”
  } else {
    // 网络错误、服务端错误或解析错误
  }
}

同理,缓存层不能把一个用户的私有响应提供给另一个用户;刷新 Token 的请求不能被普通数据缓存;登出后应清除内存认证状态并使服务端 Refresh Token 失效。安全边界与请求取消、竞态、缓存、错误恢复是同一个系统中的状态问题,而不是互不相关的主题。


十三、一份可执行的安全边界清单

提交 Vue 功能前,可以逐项回答:

  1. 这个值来自用户、URL、后端还是依赖包?
  2. 它最终会被当成文本、HTML、URL、CSS、JavaScript 还是请求头?
  3. 是否使用了 v-htmlinnerHTML、动态外链或 window.open
  4. 富文本是否在服务端和客户端经过白名单清洗?
  5. 所有链接是否限制为允许的协议和主机?
  6. 跳转参数是否防止开放重定向?
  7. Token 是否进入 URL、日志、错误上报或构建产物?
  8. Cookie 是否根据实际跨站需求设置了 SecureHttpOnlySameSite
  9. Cookie 认证的状态变更请求是否有 CSRF 防护?
  10. CORS 是否使用精确 Origin 白名单,而不是依赖它防 CSRF?
  11. 多个请求同时过期时,刷新逻辑是否避免竞态和无限重试?
  12. 依赖锁文件是否提交,安装脚本和新增包是否经过审查?
  13. VITE_ 环境变量中是否误放了秘密?
  14. CSP 是否与实际脚本、API 和静态资源域名匹配?
  15. 安全修复后是否验证了正常功能、失败路径和回滚路径?

安全边界的核心不是“Vue 会自动转义”或“使用了某个安全库”,而是始终区分:数据、解释器、凭证携带机制和信任来源。只要一个普通字符串跨入 HTML、URL、Token、Cookie、依赖安装或富文本解析边界,就必须重新证明它的安全性。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。