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 是否安全。
一、先建立威胁模型:数据会被谁解释
安全分析至少要区分四类对象:
- 数据来源:用户输入、URL 参数、后端响应、第三方接口、环境变量、依赖包。
- 解释器:HTML 解析器、JavaScript 引擎、URL 解析器、CSS 解析器、浏览器 Cookie 机制。
- 凭证携带方式:显式放入请求头,还是浏览器自动附带 Cookie。
- 信任边界:前端代码、后端服务、同源页面、跨站页面、第三方脚本之间并不天然互信。
例如,下面三个字符串看起来都只是字符串,但风险完全不同:
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;
- 只有 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>
传播过程是:
- 攻击者提交包含事件处理器的字符串;
- 服务端保存字符串并在接口中返回;
- Vue 执行
v-html; - 浏览器将字符串解析为 HTML;
onerror事件处理器执行;- 当前页面中的脚本权限被滥用,例如读取可访问的数据、调用接口或修改页面。
这里不需要攻击者直接窃取 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 构造
查询参数应使用 URL 和 URLSearchParams 构造:
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; SecureCookie; - 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,跨站请求伪造)利用的是浏览器的自动行为。
一个简化条件是:
典型场景:
- 用户已登录
bank.example,Cookie 保存在浏览器中; - 用户访问攻击者控制的
evil.example; - 恶意页面提交一个表单到
bank.example/transfer; - 浏览器自动携带
bank.example的 Cookie; - 银行服务端若只检查 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 防护通常只应用于会改变状态的请求:
POSTPUTPATCHDELETE
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 消失,需要通过刷新接口恢复会话。
这也是“请求状态”和“认证状态”必须分离的原因。loading、error、unauthorized、forbidden 和 conflict 不是同一个状态。
七、依赖安全:构建过程也是运行时供应链
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
当作无风险修复。它可能升级主版本,改变运行行为。正确流程通常是:
- 查看漏洞受影响的直接依赖和传递依赖;
- 确认漏洞是否进入生产包或仅存在于开发工具;
- 阅读修复版本的变更说明;
- 在分支中升级;
- 执行类型检查、单元测试、构建和关键流程验证;
- 再合并并记录回滚方式。
package-lock.json、pnpm-lock.yaml 或 yarn.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。因此富文本处理至少包含两步:
- 解析 Markdown 或 HTML;
- 对输出 HTML 的标签、属性和协议进行白名单清洗。
不要使用正则表达式试图完整解析 HTML。HTML 具有嵌套、实体、属性引号、命名空间和浏览器容错规则,简单替换很容易被绕过。
9.2 SVG、样式和资源加载
富文本中以下内容需要额外审查:
- SVG 标签和 SVG 外链;
style属性和 CSS URL;src、srcset、poster等资源属性;iframe、object、embed;form、action;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() 解析后比较 origin、protocol、hostname 等结构化字段,并处理异常和重定向策略。
十一、诊断方法:从数据流而不是报错文本开始
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 是否带有
Secure、HttpOnly、SameSite; - 状态变更请求是否带 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 功能前,可以逐项回答:
- 这个值来自用户、URL、后端还是依赖包?
- 它最终会被当成文本、HTML、URL、CSS、JavaScript 还是请求头?
- 是否使用了
v-html、innerHTML、动态外链或window.open? - 富文本是否在服务端和客户端经过白名单清洗?
- 所有链接是否限制为允许的协议和主机?
- 跳转参数是否防止开放重定向?
- Token 是否进入 URL、日志、错误上报或构建产物?
- Cookie 是否根据实际跨站需求设置了
Secure、HttpOnly、SameSite? - Cookie 认证的状态变更请求是否有 CSRF 防护?
- CORS 是否使用精确 Origin 白名单,而不是依赖它防 CSRF?
- 多个请求同时过期时,刷新逻辑是否避免竞态和无限重试?
- 依赖锁文件是否提交,安装脚本和新增包是否经过审查?
VITE_环境变量中是否误放了秘密?- CSP 是否与实际脚本、API 和静态资源域名匹配?
- 安全修复后是否验证了正常功能、失败路径和回滚路径?
安全边界的核心不是“Vue 会自动转义”或“使用了某个安全库”,而是始终区分:数据、解释器、凭证携带机制和信任来源。只要一个普通字符串跨入 HTML、URL、Token、Cookie、依赖安装或富文本解析边界,就必须重新证明它的安全性。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 可访问性与交互质量:语义、键盘、焦点、ARIA 和动效
- 下一篇:Vue 错误处理与可观测性:Error Boundary、日志、性能和发布诊断
- 延伸:Vue 异步数据与请求状态:取消、竞态、缓存、分页和错误恢复
- 延伸:Vue 生产交付:环境配置、静态资源、缓存、灰度和回滚
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论