3、XSS防御实战:输入过滤、输出编码、CSP策略配置与绕过案例

XSS(跨站脚本攻击),说白了就是攻击者把恶意脚本塞进你的页面里。我见过太多团队在这上面栽跟头了。今天咱们就聊聊怎么防,以及那些你以为防住了、其实根本没防住的坑。

3.1 输入过滤:第一道防线,但不是万能药

很多人一上来就说「过滤用户输入」。嗯,这个思路没错,但容易走偏。我早期做项目时也犯过这个错——以为把 <script> 标签过滤掉就万事大吉了。

实际上,输入过滤要分场景:

  • 白名单策略:只允许你明确允许的内容。比如用户填手机号,你就只允许数字和短横线。
  • 黑名单策略:拦截已知的危险字符。比如 <script>onerror=javascript: 等。
⚠️ 注意:黑名单永远有漏网之鱼。攻击者比你更懂浏览器解析规则。

举个例子,你以为过滤了 <script>,人家用 <img src=x onerror=alert(1)> 照样能打穿。为什么?因为 onerror 事件根本不需要 <script> 标签。

// 不推荐:只过滤 script 标签
function filterXSS(input) {
  return input.replace(/<script>/gi, '');
}

// 推荐:使用成熟的库,比如 DOMPurify
// 或者自己写白名单过滤
function safeFilter(input) {
  const allowedTags = ['b', 'i', 'u', 'a'];
  // 只保留白名单内的标签,其余全部转义
  return sanitize(input, allowedTags);
}
💡 我的习惯:能用现成库就别自己造轮子。DOMPurify 我用了好几年,没出过问题。

3.2 输出编码:真正该下功夫的地方

输入过滤是防君子不防小人。真正靠谱的,是输出编码。你想想看,用户数据最终要渲染到页面上,只要在「输出」这一步把危险字符转义掉,攻击脚本就执行不了。

输出编码要分上下文:

上下文 编码方式 示例
HTML 标签内容 HTML 实体编码 <&lt;
HTML 属性值 属性编码 "&quot;
JavaScript 字符串 JS 转义 '\x27
URL 参数 URL 编码 空格%20

我曾经在项目中遇到过一个问题:后端把用户昵称直接塞进 <script> 标签的变量里。前端以为后端做了编码,后端以为前端会处理。结果呢?攻击者昵称写了个 ';alert(1)//,直接闭合了引号,执行了恶意代码。

// ❌ 错误做法:直接拼接
const userData = '<script>var name = "' + userName + '";</script>';

// ✅ 正确做法:使用 JSON.stringify 或 encodeURIComponent
const safeName = JSON.stringify(userName);
const userData = `<script>var name = ${safeName};</script>`;
核心原则:永远不要信任任何用户输入。输出到哪,就按哪的规则编码。

3.3 CSP 策略配置:浏览器级别的最后防线

就算你的过滤和编码都出了问题,CSP(内容安全策略)还能兜底。说白了,CSP 就是告诉浏览器:「哪些资源可以加载,哪些脚本可以执行」。

我建议所有新项目都默认开启 CSP。配置方式很简单,在 HTTP 响应头里加一行:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'unsafe-inline';

几个关键指令:

  • default-src:默认策略,所有资源类型都适用
  • script-src:控制脚本来源
  • style-src:控制样式来源
  • img-src:控制图片来源
  • connect-src:控制 AJAX、WebSocket 等请求
⚠️ 注意:不要轻易用 'unsafe-inline''unsafe-eval'。这两个一开,CSP 的防护效果直接打五折。

我记得有个客户,他们的 CSP 配了 script-src 'self' 'unsafe-inline'。结果攻击者通过评论功能插入了 <script>alert(1)</script>,照样执行。为什么?因为 'unsafe-inline' 允许所有内联脚本执行。

更好的做法是用 noncehash

// 服务端生成随机 nonce 值
const nonce = crypto.randomBytes(16).toString('hex');
res.setHeader('Content-Security-Policy', `script-src 'nonce-${nonce}'`);

// 前端只加载带正确 nonce 的脚本
<script nonce="<%= nonce %>">
  // 这是安全的脚本
</script>

3.4 绕过案例:你以为防住了?

讲几个我实际遇到过的绕过案例。看完你就知道,攻击者的脑洞有多大。

案例一:Unicode 编码绕过

有些过滤只检查 ASCII 字符,但浏览器会解析 Unicode 编码。比如:

// 攻击者输入
\u003cscript\u003ealert(1)\u003c/script\u003e

// 如果过滤只检查 < 和 >,这个就漏过去了
// 浏览器解析后,\u003c 会变成 <

案例二:事件处理器绕过

过滤了 onerror,还有 onloadonfocusonmouseover…… 甚至 onpointerenter

<input onfocus="alert(1)" autofocus>
<details open ontoggle="alert(1)">

案例三:CSS 注入绕过

你以为 CSS 就安全了?看看这个:

<style>
  body {
    background-image: url('javascript:alert(1)');
  }
</style>

老版本 IE 和某些浏览器会执行 javascript: 伪协议。现在虽然少了,但谁知道呢?

💡 避坑指南:我曾经在做一个富文本编辑器时,只过滤了 <script>onerror。结果攻击者用 <svg onload=alert(1)> 打穿了。从那以后,我所有富文本内容都强制走 DOMPurify。

3.5 实战建议:三层防御体系

说了这么多,总结一下我的实战经验。防御 XSS 不能只靠一层,要三层叠加:

  1. 输入层:白名单过滤,只保留你明确允许的内容
  2. 输出层:根据上下文做正确的编码
  3. 浏览器层:配置严格的 CSP 策略兜底

这三层只要有一层没破,攻击就成功不了。我现在的项目都是这么配的,几年下来没出过 XSS 漏洞。

最后说一句:安全不是一次性的工作。每次上线前,记得用自动化工具扫一遍。OWASP ZAP 或者 Burp Suite 都行,免费的够用了。