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

聊到XSS防御,我见过太多人一上来就甩一句「过滤掉特殊字符就行了」。嗯,要是真这么简单,安全工程师早该失业了。XSS的防御是个系统工程,不是单靠一个函数就能搞定的。今天咱们就把这块掰开揉碎了讲。

3.1 输入过滤:别把宝全押在这

输入过滤,说白了就是不让恶意数据进到系统里。但这里有个坑——你永远不知道用户的数据会流向哪里。

白名单 vs 黑名单

我个人习惯用白名单。黑名单就像打地鼠,你封了<script>,攻击者就用<img onerror>;你封了onerror,人家又搞出<svg onload>。没完没了。

白名单示例:只允许特定标签和属性

// 用DOMPurify做白名单过滤
import DOMPurify from 'dompurify';

const clean = DOMPurify.sanitize(dirtyInput, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a'],
  ALLOWED_ATTR: ['href', 'title', 'target'],
  ALLOW_DATA_ATTR: false
});

我在项目中遇到过,有人自己写正则过滤<script>,结果攻击者用<ScRiPt>大小写混写就绕过去了。所以啊,别自己造轮子,用成熟的库。

注意:输入过滤不能作为唯一防线。数据可能经过多层流转,比如从API来、从数据库来,这些地方你没法保证都过滤了。

3.2 输出编码:这才是真正的护城河

输出编码,说白了就是把数据里的特殊字符转义掉,让浏览器不把它当代码执行。这是防御XSS最核心的一步。

不同上下文,不同编码方式

你想想看,数据出现在HTML标签里、出现在JavaScript里、出现在URL里,编码方式能一样吗?当然不能。

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

小技巧:在React、Vue这类框架里,默认的模板语法已经做了输出编码。但要注意dangerouslySetInnerHTMLv-html,用了它们就等于绕过了框架的保护。

// 错误的做法:直接插入HTML
element.innerHTML = userInput;  // 危险!

// 正确的做法:用textContent
element.textContent = userInput;  // 安全

// 如果必须用innerHTML,先编码
function encodeHTML(str) {
  return str.replace(/&/g, '&')
            .replace(//g, '>')
            .replace(/"/g, '"')
            .replace(/'/g, ''');
}

3.3 CSP策略配置:最后一道防线

CSP(内容安全策略)是个好东西。它就像给浏览器下了一道命令:「哪些资源可以加载,哪些不行」。就算攻击者注入了恶意脚本,CSP也能拦住它不让执行。

配置示例

// 通过HTTP头设置CSP
Content-Security-Policy: 
  default-src 'self';
  script-src 'self' https://trusted-cdn.com;
  style-src 'self' 'unsafe-inline';
  img-src 'self' data:;
  object-src 'none';
  base-uri 'none';

这里有几个关键点:

  • default-src 'self':只信任同源资源
  • script-src:严格控制脚本来源
  • object-src 'none':禁止插件,Flash之类的别想了
  • base-uri 'none':防止base标签劫持

我曾经踩过的坑:一开始把CSP设得太严,结果页面上的内联脚本全挂了,连Google Analytics都跑不了。后来才学会用'unsafe-inline'配合nonce或hash来精确控制。

Nonce vs Hash

如果你必须用内联脚本,别用'unsafe-inline',那等于把门打开了。用nonce或者hash更安全。

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

// 页面中引用
<script nonce="${nonce}">
  // 你的内联脚本
</script>

3.4 绕过案例:知己知彼

光知道怎么防还不够,得知道攻击者怎么绕。我整理了几个常见的绕过手法。

案例1:编码绕过

攻击者把<script>用URL编码、Unicode编码、甚至HTML实体编码混着来。

// 绕过简单过滤
<img src=x onerror="alert(1)">
// 上面这串编码解码后是 alert(1)

案例2:DOM clobbering

利用DOM元素的id或name覆盖全局变量。

<form id="config">
  <input name="url" value="javascript:alert(1)">
</form>
<script>
  // 如果代码里用了 config.url,就会被这个input覆盖
  location.href = config.url;  // 危险!
</script>

案例3:CSP绕过

如果CSP里允许了JSONP接口,攻击者可以利用它来执行任意代码。

// 假设CSP允许了 https://api.example.com
// 攻击者利用JSONP回调
<script src="https://api.example.com/jsonp?callback=alert(1)"></script>

防御要点:

  • 永远不要信任用户输入,哪怕它经过了过滤
  • 输出编码要针对上下文,别一刀切
  • CSP要配置合理,别太松也别太紧
  • 定期做安全审计,用工具扫描潜在漏洞

最后说一句,XSS防御没有银弹。输入过滤、输出编码、CSP这三层防线缺一不可。我见过太多项目只做了其中一层,结果被攻破后追悔莫及。嗯,安全这事儿,还是做全面点好。