现代前端框架 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>&lt;script&gt;alert(1)&lt;/script&gt;</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, '&amp;')
                  .replace(/>/g, '&gt;')
                  .replace(/</g, '&lt;')
                  .replace(/"/g, '&quot;');
    }
    return str;
}

所以 <div>{'<b>hi</b>'}</div> 渲染出来是 <div>&lt;b&gt;hi&lt;/b&gt;</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 跨框架通用防御清单

  1. 永远不信任用户输入:无论框架怎么转义,涉及"富文本渲染"时必须走 DOMPurify
  2. 禁用危险 API:用 ESLint 规则禁用 dangerouslySetInnerHTMLv-htmlbypassTrust*
  3. CSP 强制:生产环境开启严格 CSP,只允许信任域的脚本
  4. 审计高危点:搜索项目中所有使用了"bypass"、"dangerously"、"v-html"、"innerHTML"的地方
  5. 类型系统: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 虽然没在命名上做这么极端的提醒,但风险完全相同。