往期文章:
1 XSS 漏洞的本质
原理:当网站将用户输入的数据当作代码执行而非普通文本时,攻击者就能注入恶意脚本。
Vue 官方说明:
“Vue 会自动转义 HTML 内容,防止你在模板中意外注入可执行代码。但当你使用
v-html
时,需要特别小心。”—— Vue 安全指南
React 官方警告:
“React DOM 在渲染前会转义所有字符串,防止 XSS 攻击。但使用
dangerouslySetInnerHTML
相当于直接操作 DOM,需要自行确保内容安全。”
关键点:
- 浏览器无法区分开发者编写的代码和用户输入的恶意代码。
- 漏洞发生在数据输出到 HTML 的位置,而非输入时。
示例代码:
<!-- 漏洞代码:用户输入直接拼接到 HTML -->
<div>
欢迎您,<%= request.getParameter("username") %>
</div>
<!-- 攻击者输入: -->
<script>alert('XSS!')</script>
2 XSS 漏洞类型介绍
类型 | 存储型 XSS | 反射型 XSS | DOM 型 XSS |
---|---|---|---|
特征 | 恶意脚本存储在服务器,所有访问者触发 | 恶意脚本在 URL 中,需诱导用户点击 | 完全在浏览器端执行,不经过服务器 |
攻击步骤 | 1. 提交恶意脚本到评论区等存储位置 2. 其他用户访问时执行 | 1. 构造含恶意脚本的 URL 2. 用户点击后在结果页执行 | 1. 恶意脚本嵌入 URL 片段 2. 浏览器解析 DOM 时执行 |
典型 Payload | <img src=x onerror=alert(1)> | ';alert(1);// | http://example.com#<img src=x onerror=alert(1)> |
3 XSS 漏洞代码示例
3.1 存储型场景
// 留言板代码(Node.js示例)
app.post('/comment', (req, res) => {
db.saveComment(req.body.text); // 直接存储未过滤
});
// 显示留言
app.get('/comments', (req, res) => {
const comments = db.getComments();
res.send(comments.map(c => `<div>${c.text}</div>`)); // 直接输出
});
框架中的触发点:
<!-- Vue 危险示例 -->
<template>
<div>
<!-- 用户评论直接渲染 -->
{{ unescapedComment }}
<!-- 更危险的渲染方式 -->
<div v-html="userContent"></div>
</div>
</template>
// React 危险示例
function CommentSection({ comments }) {
return (
<div>
{comments.map(comment => (
// 直接渲染未过滤内容
<div key={comment.id}>{comment.text}</div>
))}
{/* 危险操作 */}
<div dangerouslySetInnerHTML={{ __html: userContent }} />
</div>
);
}
3.2 反射型场景
<!-- 搜索页面(PHP示例) -->
<p>您搜索的关键词: <?php echo $_GET['q']; ?></p>
框架中的触发点:
<script>
export default {
mounted() {
// 从 URL 获取参数直接使用
const searchTerm = this.$route.query.q;
this.searchResults = fetchResults(searchTerm);
}
}
</script>
// React 危险示例
function SearchPage() {
const [search] = useSearchParams();
const term = search.get('q');
// 直接渲染 URL 参数
return <h2>搜索: {term}</h2>;
}
3.3 DOM 型场景
// 动态加载URL片段
window.onload = () => {
const userToken = location.hash.substring(1);
document.write(`您的token: ${userToken}`); // 危险操作!
};
框架中的触发点:
<script>
export default {
methods: {
loadContent() {
// 直接操作 DOM
document.getElementById('preview').innerHTML = this.userContent;
}
}
}
</script>
// React 危险示例
function UserProfile() {
useEffect(() => {
// 直接设置 innerHTML
document.querySelector('.bio').innerHTML = userBio;
}, []);
return <div className="bio"></div>;
}
4 框架中的 XSS 表现与防御
4.1 Vue 中的安全实践
不安全的动态属性绑定
<template>
<!-- 危险:用户控制属性名 -->
<div :[userKey]="userValue"></div>
<!-- 危险:用户控制事件名 -->
<button @[userEvent]="handleClick">点击</button>
</template>
<script>
export default {
data() {
return {
userKey: 'onmouseover', // 攻击者可注入事件
userValue: 'alert(1)', // 可执行代码
userEvent: 'onclick' // 可被替换为恶意事件
}
}
}
</script>
修复方案:
<template>
<!-- 安全:使用静态属性名 -->
<div :data-custom="sanitizedValue"></div>
</template>
<script>
import DOMPurify from 'dompurify';
export default {
computed: {
sanitizedValue() {
// 严格限制允许的键名
const allowedKeys = ['data-id', 'data-role'];
return allowedKeys.includes(this.userKey)
? DOMPurify.sanitize(this.userValue)
: '';
}
}
}
</script>
不安全的模板插槽
<template>
<!-- 危险:用户控制插槽内容 -->
<component :is="userComponent">
<template v-slot:default>
{{ userContent }}
</template>
</component>
</template>
<script>
export default {
data() {
return {
userComponent: 'div',
userContent: '<img src=x onerror=alert(1)>'
}
}
}
</script>
修复方案:
<template>
<!-- 安全:强制使用作用域插槽 -->
<component :is="safeComponent">
<template v-slot:default="{ sanitize }">
{{ sanitize(userContent) }}
</template>
</component>
</template>
<script>
import { markRaw } from 'vue';
import SafeWrapper from './SafeWrapper.vue';
export default {
computed: {
safeComponent() {
// 仅允许安全组件
const allowed = ['div', 'span', SafeWrapper];
return allowed.includes(this.userComponent)
? markRaw(this.userComponent)
: markRaw(SafeWrapper);
}
}
}
</script>
Vue 官方推荐方案
安全模式
<template>
<!-- 安全:自动转义 -->
<p>{{ userContent }}</p>
<!-- 安全:属性绑定自动转义 -->
<a :href="safeUrl">链接</a>
</template>
危险场景处理
<template>
<!-- 必须使用 v-html 时 -->
<div v-html="sanitizedHtml"></div>
</template>
<script>
import DOMPurify from 'dompurify';
export default {
computed: {
sanitizedHtml() {
return DOMPurify.sanitize(this.rawHtml, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong'],
FORBID_ATTR: ['style', 'on*']
});
}
}
}
</script>
动态组件安全
<script>
const safeComponents = ['BaseButton', 'BaseIcon'];
export default {
computed: {
safeComponent() {
return safeComponents.includes(this.userComponent)
? this.userComponent
: 'FallbackComponent';
}
}
}
</script>
Vue 官方建议:
- 避免使用
v-html
,优先使用插值表达式- 警惕 SSR 中的 Hydration 不匹配风险
- 对非受信内容使用 DOMPurify 清理
4.2 React 中的安全实践
不安全的动态标签名
// 危险:用户控制标签名
function DynamicTag({ tagName, content }) {
const Tag = tagName || 'div';
return <Tag>{content}</Tag>;
}
// 攻击者使用:<DynamicTag tagName="img" content="x onerror=alert(1)" />
修复方案:
// 安全:白名单限制标签
import { isValidElementType } from 'react-is';
function SafeDynamicTag({ tagName, content }) {
const validTags = ['div', 'span', 'p', 'section'];
const Tag = validTags.includes(tagName) ? tagName : 'div';
return <Tag>{content}</Tag>;
}
// 或者使用安全包装
function SanitizedTag({ tagName, ...props }) {
const Element = tagName;
// 防止非标准标签
if (!isValidElementType(Element)) {
return <div {...props} />;
}
// 过滤危险属性
const safeProps = Object.keys(props).reduce((acc, key) => {
if (!key.startsWith('on') && key !== 'dangerouslySetInnerHTML') {
acc[key] = props[key];
}
return acc;
}, {});
return <Element {...safeProps} />;
}
不安全的 ref 使用
// 危险:通过 ref 直接操作 DOM
function UnsafeComponent() {
const divRef = useRef(null);
useEffect(() => {
// 攻击者可控制外部输入
divRef.current.innerHTML = externalInput;
}, []);
return <div ref={divRef} />;
}
修复方案:
// 安全:使用受控组件
function SafeComponent() {
const [safeContent, setSafeContent] = useState('');
useEffect(() => {
// 使用 DOMPurify 清理
import('dompurify').then(DOMPurify => {
setSafeContent(DOMPurify.sanitize(externalInput));
});
}, []);
return <div>{safeContent}</div>;
}
// 或使用 React 受控方式
function RefSanitizer() {
const divRef = useRef(null);
useEffect(() => {
if (divRef.current) {
// 使用 textContent 替代 innerHTML
divRef.current.textContent = externalInput;
}
}, []);
return <div ref={divRef} />;
}
不安全的第三方库集成
// 危险:直接渲染 Markdown 不处理
import ReactMarkdown from 'react-markdown';
function MarkdownViewer({ content }) {
return <ReactMarkdown>{content}</ReactMarkdown>;
}
// 攻击者输入:)
修复方案:
// 安全:配置渲染规则
import ReactMarkdown from 'react-markdown';
import rehypeSanitize from 'rehype-sanitize';
function SafeMarkdown({ content }) {
return (
<ReactMarkdown
rehypePlugins={[rehypeSanitize]}
components={{
a: ({ node, ...props }) => (
<a {...props} rel="noopener noreferrer" target="_blank" />
),
img: ({ node, ...props }) => (
<img {...props} onError={(e) => e.target.style.display='none'} />
)
}}
>
{content}
</ReactMarkdown>
);
}
React 官方推荐方案
安全渲染
// 安全:自动转义
function SafeComponent({ text }) {
return <div>{text}</div>;
}
// 安全:URL 验证
function SafeLink({ url, children }) {
const isValid = url.startsWith('https://') || url.startsWith('/');
return isValid ? (
<a href={url} rel="noopener noreferrer">{children}</a>
) : (
<span>{children}</span>
);
}
危险内容处理
import DOMPurify from 'dompurify';
function SanitizedHTML({ html }) {
const clean = DOMPurify.sanitize(html, {
FORBID_TAGS: ['style', 'script'],
FORBID_ATTR: ['onclick', 'onerror']
});
return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}
安全数据流
// 使用受控组件防止注入
function ContactForm() {
const [message, setMessage] = useState('');
return (
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
);
}
React 官方建议:
- 所有渲染内容默认转义,除非使用
dangerouslySetInnerHTML
- 使用 JSX 语法防止注入攻击
- 对富文本内容使用 sanitization 库
4.3 JQuery 安全实践
// 危险操作
$('#output').html(userInput);
// 安全方案1:使用text()
$('#output').text(userInput);
// 安全方案2:过滤后使用
const clean = DOMPurify.sanitize(userInput);
$('#output').html(clean);
5 XSS 检测方法
5.1 人工检测指南
// 常用测试 Payload 清单
const payloads = [
'<script>alert(1)</script>', // 基础检测
'<img src=x onerror=alert(1)>', // 绕过基础过滤
'javascript:alert(1)', // 测试URL上下文
'\'";alert(1)//', // 测试JS字符串中断
'%26%23x3c;script%26%23x3e;' // HTML实体编码绕过
];
// 检测步骤:
1. 在输入框依次提交payloads
2. 检查页面是否弹窗/执行脚本
3. 查看HTML源码确认特殊字符是否转义
5.2 自动化工具使用
BurpSuite (专业版)检测流程:
- 配置浏览器代理
- 正常浏览网站抓包HTTP请求
- 右键点击请求 → “Do active scan”
- 查看报告中的"Cross-site Scripting"条目
其他工具推荐:
- XSS Hunter(自动捕获漏洞)
- Retire.js(检测JS依赖库漏洞)
6 XSS 防御代码
6.1 内容安全策略 (CSP)
Vue 、React 均适用
<!-- 在 index.html 中添加 CSP 头 -->
<meta http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self' 'unsafe-eval' https://trusted.cdn.com;
style-src 'self' 'unsafe-inline';
img-src * data:;
connect-src 'self';
object-src 'none';">
6.2 服务端防御
Node.js 示例
// HTML实体编码函数
function escapeHTML(str) {
return str.replace(/[&<>"']/g, char =>
({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
}[char])
);
}
// Express中间件
app.use((req, res, next) => {
// 对所有输出数据编码
res.locals.escape = escapeHTML;
next();
});
// 模板中使用
<p><%= escape(userContent) %></p>
6.3 中间件配置
Nginx 配置
add_header Content-Security-Policy "default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src * 'self':;
font-src 'self';
connect-src 'self';
object-src 'none';
frame-ancestors 'none';";
6.4 服务端 Cookie 安全设置
// Java Servlet 示例
@Component
public class CookieSecurityFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
HttpServletResponseWrapper wrapper = new HttpServletResponseWrapper((HttpServletResponse) res) {
@Override
public void addCookie(Cookie cookie) {
cookie.setSecure(true); // 仅 HTTPS 传输
cookie.setHttpOnly(true); // 防止 XSS
cookie.setSameSite("Strict"); // 禁止跨站发送
cookie.setPath("/"); // 路径范围
cookie.setMaxAge(3600); // 有效期 1 小时
super.addCookie(cookie);
}
};
chain.doFilter(req, wrapper);
}
}
// Spring Security 示例
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(session -> session
.sessionCookie(cookie -> cookie
.name("MY_SESSION") // 自定义 Session Cookie 名称
.secure(true) // 仅 HTTPS
.httpOnly(true) // 防止 XSS
.sameSite(SameSite.CookieSameSite.LAX) // 跨站限制
)
)
... // 其他配置
);
return http.build();
}
}
// Spring Session 示例
// 1. 配置文件
# application.yml
spring:
session:
cookie:
name: SPRING_SESSION # 自定义 Session Cookie 名称
secure: true # 仅 HTTPS
http-only: true # 防止 XSS
same-site: lax # 跨站限制 (Spring Boot 3 支持)
max-age: 3600 # 有效期 1 小时
store-type: redis # 存储方式(可选)
// 2. 代码配置
@Configuration
public class SessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
serializer.setCookieName("XXX_SESSION");
serializer.setUseSecureCookie(true);
serializer.setUseHttpOnlyCookie(true);
serializer.setSameSite("LAX");
return serializer;
}
}
7 安全测试速查
-
输入测试:在所有输入点提交
<img src=x onerror=alert(1)>
-
输出检查:
- 查看页面是否弹窗
- 按 F12 检查元素,看尖括号是否变成
<
-
DOM 检测:在URL后添加
#<script>alert(1)</script>
测试前端渲染 -
工具扫描:
# 使用命令行工具 xsser -u "http://example.com/search?q=test"
-
防御验证:
- 检查 HTTP 响应头是否有
Content-Security-Policy
- 查看 Cookie 是否含
HttpOnly
属性
- 检查 HTTP 响应头是否有
8 总结
核心原则:所有来自外部的数据在输出时,必须根据上下文进行转义或过滤!
一句话口诀:数据渲染前必转义,动态内容必过滤,CSP 与 Cookie 安全必配置。