现代前端框架 XSS 剖析:React / Vue / Angular 各自的安全边界
一、为什么现代框架"默认安全"
在 jQuery 时代,XSS 泛滥是因为开发者频繁使用 $(el).html()、document.write() 等危险 API。框架时代的改变在于——默认安全:
| 框架 | 数据绑定方式 | 默认行为 |
|---|---|---|
| React | JSX {data} |
对数据做 HTML 实体转义 |
| Vue | 模板插值 {{ data }} |
对数据做 HTML 实体转义 |
| Angular | 插值表达式 {{ data }} |
对数据做 HTML 实体转义 |
这意味着:
// React
const userInput = '<script>alert(1)</script>';
return <div>{userInput}</div>;
// 渲染结果:<div><script>alert(1)</script></div>
// ✅ 安全!恶意脚本被转义为纯文本
这是框架最大的贡献之一。但框架并不会让你自动安全,它们只是帮你挡住了最显而易见的坑。真正的危险在"逃生舱"API 里。
二、React XSS 详解
2.1 JSX 默认编码的原理
React 在内部使用 escapeHtml 函数处理所有通过 {} 插值的数据:
// React 源码简化版
var PROTECTION_MESSAGE =
'React will try to recreate this component tree from scratch...';
function escapeHtml(value) {
var str = '' + value;
if (/[&<>"]/.test(str)) {
return str.replace(/&/g, '&')
.replace(/>/g, '>')
.replace(/</g, '<')
.replace(/"/g, '"');
}
return str;
}
所以 <div>{'<b>hi</b>'}</div> 渲染出来是 <div><b>hi</b></div>,而不是粗体文字。
2.2 React 中的危险 API
① dangerouslySetInnerHTML(最常见)
// ❌ 危险:直接把 HTML 字符串塞进 DOM
function UserBio({ bio }) {
return (
<div
dangerouslySetInnerHTML={{ __html: bio }}
/>
);
}
// 如果 bio = '<img src=x onerror=alert(1)>' → DOM XSS!
正确做法:用 DOMPurify 先过滤:
import DOMPurify from 'dompurify';
function UserBio({ bio }) {
return (
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(bio, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'li'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_URI_DATA_ATTR: false
})
}}
/>
);
}
② href / src 属性中的 javascript: 协议
// ❌ 危险:如果 userUrl 来自用户输入
function Link({ userUrl }) {
return <a href={userUrl}>跳转</a>;
}
// userUrl = 'javascript:alert(document.cookie)' → 点击触发 XSS
React 其实对 href/src 做了一层协议校验:在 React 16+ 中,javascript:、vbscript:、data: 都会被自动加上前缀 unsafe: 阻止执行。
但这只在 静态分析能确定是 href 属性 时生效。如果是动态属性:
// ❌ 依然危险
const props = { href: userUrl };
return <a {...props}>click</a>; // 展开运算符跳过了静态检查
正确做法:
function safeUrl(url) {
if (typeof url !== 'string') return '#';
if (/^https?:///.test(url)) return url; // 只允许 http/https
if (/^mailto:/.test(url)) return url;
return '#';
}
③ ref + innerHTML
// ❌ 危险:用 ref 拿到 DOM 节点后操作
function ChatBox() {
const ref = useRef(null);
useEffect(() => {
const newMsg = getUserInput();
ref.current.innerHTML = newMsg; // DOM XSS!
});
return <div ref={ref}></div>;
}
替换方案:
// ✅ 用 textContent
ref.current.textContent = newMsg;
// ✅ 或用 React 的状态驱动
const [msg, setMsg] = useState('');
// ...
<div>{msg}</div>
④ eval / Function 构造
// ❌ 危险:把用户输入当代码执行
const code = userInput;
const fn = new Function('return ' + code);
const result = fn();
// 用户输入:"1; alert(document.cookie);" → 执行任意代码
⑤ React Router 的动态重定向
// ❌ 危险:从 URL 读取参数直接跳转
function LoginRedirect() {
const navigate = useNavigate();
const redirect = new URLSearchParams(location.search).get('redirect');
useEffect(() => { navigate(redirect); }, []);
}
// redirect = 'javascript:alert(1)'
// 现代 React Router v6 会做 URL 校验,但 v5 不会
2.3 React Server Components(RSC)与 XSS
RSC 让组件在服务端渲染,但服务端代码依然会被执行。如果服务端组件里做了不安全操作,XSS 会直接注入到 HTML 响应里,和传统反射型 XSS 同理。
三、Vue XSS 详解
3.1 模板插值 vs v-html
Vue 和 React 类似,{{ }} 插值会做 HTML 转义:
<!-- ✅ 安全:转义输出 -->
<div>{{ userInput }}</div>
<!-- ❌ 危险:原样输出 HTML -->
<div v-html="userInput"></div>
3.2 v-html 的正确使用
// ❌ 危险写法
<div v-html="article.content"></div>
// ✅ 安全写法
import DOMPurify from 'dompurify';
import { sanitize } from 'dompurify';
// 方案 A:在组件内预处理
computed: {
safeContent() {
return DOMPurify.sanitize(this.article.content, {
ALLOWED_TAGS: ['p','h1','h2','h3','b','i','em','strong','a','img','ul','li','ol','br','hr'],
ALLOWED_ATTR: ['href','src','alt','title','class','id']
});
}
}
// 模板里用:<div v-html="safeContent"></div>
// 方案 B:自定义全局指令(一劳永逸)
Vue.directive('sanitized-html', {
bind(el, binding) {
el.innerHTML = DOMPurify.sanitize(binding.value);
},
update(el, binding) {
if (binding.value !== binding.oldValue) {
el.innerHTML = DOMPurify.sanitize(binding.value);
}
}
});
// 模板里用:<div v-sanitized-html="article.content"></div>
3.3 Vue 中的危险 API 全集
| API | 严重程度 | 场景 |
|---|---|---|
v-html |
🔴 极高 | 富文本渲染 |
v-bind:src="..." |
🟠 高 | 动态 iframe/img 地址 |
v-bind:href="..." |
🟠 高 | 动态链接 |
this.$el.innerHTML = ... |
🔴 极高 | ref 操作 DOM |
document.write() |
🔴 极高 | 几乎只在老代码里 |
eval() |
🔴 极高 | 动态执行 |
new Function() |
🔴 极高 | 同上 |
<script> 标签写入 |
🔴 高 | innerHTML / v-html |
<iframe srcdoc> |
🟠 中 | 嵌入动态 HTML |
3.4 Vue 3 Composition API 下的风险
// Vue 3
<script setup>
import { ref, onMounted } from 'vue';
const userInput = ref('');
const container = ref(null);
onMounted(() => {
// ❌ 危险:从 ref 直接 innerHTML
fetch('/api/comment/latest')
.then(r => r.text())
.then(html => {
container.value.innerHTML = html;
});
});
</script>
<template>
<div ref="container"></div>
</template>
3.5 Vue SFC 中的 xss 场景:模板本身从远端加载
// ❌ 危险:远程模板编译
const remoteTemplate = await fetch(userProvidedUrl).then(r => r.text());
// 如果 remoteTemplate 包含恶意代码
// Vue.compile(remoteTemplate) 会生成并执行 JS
const compiled = Vue.compile(remoteTemplate);
// 攻击 Payload: {{constructor.constructor('return alert(1)')()}}
// Vue 的模板编译是可被利用的
3.6 Vue 模板中的 HTML 注入技巧(已修复历史 CVE)
// CVE-2019-6486 Vue < 2.6.10
// v-bind 的对象展开可以绕过 HTML escape
// Payload:
// <div v-bind="userInput"></div>
// userInput = '{id:"onmouseover",onmouseover:"alert(1)"}'
// 但 Vue 2.6.10 后已修复
// CVE-2020-7659 Vue < 2.6.12 / < 3.0.6
// 事件处理器中的注入
四、Angular XSS 详解
4.1 Angular 的安全机制更强硬
Angular 引入了值消毒(Sanitization)和安全上下文(SecurityContext):
| 上下文 | 示例 | Angular 行为 |
|---|---|---|
| HTML | <div>{{ htmlData }}</div> |
自动转义为纯文本 |
| Attribute | <div title="{{ data }}"> |
转义 |
| URL | <a href="{{ url }}"> |
允许 http/https/mailto,拒绝 javascript: |
| Style | [style]="cssData" |
转义并过滤危险 CSS 表达式 |
| Resource URL | <script src="{{ url }}"> |
❌ 直接拦截不加载 |
4.2 Angular 的危险"逃生舱"
bypassSecurityTrustHtml
// ❌ 危险:绕过消毒直接信任 HTML
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
constructor(private sanitizer: DomSanitizer) {}
// 告诉 Angular "我信任这段 HTML",不再做任何消毒
getSafeHtml(data: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(data);
}
// 模板: <div [innerHTML]="getSafeHtml(userInput)"></div>
// userInput = '<img src=x onerror=alert(1)>' → 直接执行!
bypassSecurityTrustUrl
// ❌ 危险
getSafeUrl(url: string): SafeUrl {
return this.sanitizer.bypassSecurityTrustUrl(url);
}
// 模板: <a [href]="getSafeUrl(userInput)">click</a>
// userInput = 'javascript:alert(1)' → 执行!
4.3 Angular 模板中的沙盒与绕过
Angular JS 1.x 的 ng-bind-html + $sce.trustAsHtml:
<!-- Angular 1.x -->
<!-- ❌ 危险 -->
<div ng-bind-html="trustedHtml"></div>
<script>
$scope.trustedHtml = $sce.trustAsHtml(userInput);
</script>
<!-- 用 ngSanitize 模块可以挡住一部分,但不是全部 -->
<div ng-bind-html="userInput"></div> <!-- 需要加载 ngSanitize -->
4.4 Angular 表达式注入
Angular 模板中的 {{ expression }} 是可以执行任意 JS 表达式的:
// 早期 AngularJS 可以用构造器逃逸:
{{constructor.constructor('return alert(1)')()}}
// 或:
{{7*7}}
// 输出 49 —— 证明模板是表达式求值,不是字符串替换
在 Angular 2+ 中这个风险大幅降低,但依然要注意不要让用户输入影响模板本身。
4.5 Angular DomSanitizer 的正确做法
// ❌ 危险做法
safeHtml = this.sanitizer.bypassSecurityTrustHtml(article.richContent);
// ✅ 正确做法:自己先 DOMPurify,再 bypassTrust(你已经信任了)
import DOMPurify from 'dompurify';
safeHtml = this.sanitizer.bypassSecurityTrustHtml(
DOMPurify.sanitize(article.richContent, {
ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'a', 'ul', 'li'],
ALLOWED_ATTR: ['href', 'title']
})
);
五、三框架对比与最佳实践
5.1 危险 API 对照表
| 危险动作 | React | Vue | Angular |
|---|---|---|---|
| 渲染 HTML 字符串 | dangerouslySetInnerHTML |
v-html |
[innerHTML] + bypass |
| 绕过消毒 | 框架本身无绕过 API | 框架本身无绕过 API | DomSanitizer.bypassTrust* |
| 动态 iframe | <iframe src={url}> + 协议校验 |
同左 | bypassSecurityTrustResourceUrl |
| 动态脚本 | 无合法途径 | 无合法途径 | 无合法途径 |
| 模板编译执行 | JSX 是编译时的 | Vue 模板编译是安全的 | 动态模板编译有风险 |
5.2 跨框架通用防御清单
- 永远不信任用户输入:无论框架怎么转义,涉及"富文本渲染"时必须走 DOMPurify
- 禁用危险 API:用 ESLint 规则禁用
dangerouslySetInnerHTML、v-html、bypassTrust* - CSP 强制:生产环境开启严格 CSP,只允许信任域的脚本
- 审计高危点:搜索项目中所有使用了"bypass"、"dangerously"、"v-html"、"innerHTML"的地方
- 类型系统:TypeScript + 自定义类型,防止把不可信数据标记为 SafeHtml
5.3 ESLint 规则示例
// .eslintrc.js —— React 项目禁用危险 API
module.exports = {
rules: {
'react/no-danger': 'error', // 禁止 dangerouslySetInnerHTML
'no-restricted-properties': [
'error',
{
object: 'document',
property: 'write',
message: 'Use React rendering instead'
},
{
object: 'document',
property: 'writeln',
message: 'Use React rendering instead'
}
],
'no-restricted-globals': [
'error',
'eval',
'Function'
]
}
};
// Vue 项目 —— eslint-plugin-vue
// vue/no-v-html: 'error' // 禁止 v-html
// vue/no-restricted-syntax: ['error', 'CallExpression[callee.name="eval"]']
5.4 小结
现代框架给了你安全的默认值,但也给了你危险的逃生舱。记住一个原则:
除非你完全掌控输入内容(比如 Markdown 经过库解析后的输出),否则永远不要调用框架的"信任"API。
在 React 中,dangerouslySetInnerHTML 那个 dangerously 名字就是提醒你——这东西很危险,用之前必须想清楚。Vue 和 Angular 虽然没在命名上做这么极端的提醒,但风险完全相同。