XSS 是什么:攻击原理、防御方法与 CORS

目录

XSS、CSRF 和 CORS 经常一起出现,但它们不是同一类问题。XSS 是浏览器把不可信数据当成了代码。CSRF 是攻击者借浏览器自动带上的登录凭据发请求。CORS 则只决定一个源里的 JavaScript 能不能读取另一个源的响应。把 CORS 当作 XSS 的防线,或者觉得框架会自动处理所有注入,都是很危险的误解。

XSS 是 Cross-Site Scripting 的缩写。攻击者不一定直接把一段 <script> 放进页面。真正的问题是,应用把攻击者可控的数据塞进了会执行代码的上下文。脚本一旦运行,就和页面里的正常代码拥有相近的权限。它能读取页面上可见的信息,调用站内接口,诱导用户操作,也可能把能拿到的数据发走。

HttpOnly Cookie 只能减少脚本直接读 Cookie 的机会,不能阻止脚本以当前登录用户的身份调用接口。所以 Cookie 读不到,并不表示 XSS 没有危害。

React、Vue 和服务端模板默认会转义普通插值,这确实挡住了不少常见漏洞。但开发者一旦使用 dangerouslySetInnerHTMLv-html、模板原始输出或 innerHTML,安全责任就回来了。

XSS 从哪里进来

存储型 XSS 会先把载荷保存到数据库、缓存或文件里。评论、个人资料、工单标题和富文本内容都可能成为入口。它的麻烦不只在攻击者能否看到效果,而在于后来的每个浏览者都可能收到这段内容,管理员和运营人员尤其容易成为目标。

下面的输入本身不是漏洞。漏洞出现在应用保存它后,又把它直接当 HTML 插进页面。

<img src=x onerror="alert(document.domain)">

反射型 XSS 通常藏在 URL、表单字段或请求头里。服务器把参数回显到页面时没有做对应的编码,攻击者就能用一条链接诱导用户打开。问题不在于 GET 参数天生危险。URL 参数、请求体、数据库旧数据和第三方接口返回值,只要最终进入浏览器的可执行上下文,都应按不可信数据处理。

DOM 型 XSS 发生在前端。服务端甚至可能从未见过载荷。脚本从 locationhashpostMessagelocalStorage 取到值,再不安全地写回 DOM,就足够了。

const value = new URLSearchParams(location.search).get('default');
document.querySelector('#language').innerHTML = value;

这里应该使用文本节点。

const value = new URLSearchParams(location.search).get('default') || '';
document.querySelector('#language').textContent = value;

innerHTML 会让浏览器解析字符串里的标签和属性。textContent 只显示文本。两者只差一个 API,语义却完全不同。DOM 型 XSS 也是为什么服务端 WAF 没有报警,并不能证明页面安全。载荷如果只存在于 URL fragment,服务端日志里根本不会有它。

防御不靠过滤几个字符

只过滤 <script> 不够。浏览器会在 HTML 文本、属性、URL、JavaScript 和 CSS 这些位置用不同规则解释数据。一种位置可用的编码方式,换到另一个位置就可能失效。

普通文本应走模板默认转义,或者用 textContent。不要用字符串拼 HTML。

const userInput = '<script>alert("hello")</script>';
const div = document.createElement('div');
div.textContent = userInput;
document.body.appendChild(div);

属性也不能一概而论。titlealt 相对简单,hrefsrcstyleonclick 会引入新的解释规则。用 setAttribute 能避免字符串逃出既定属性,但链接地址仍要限制协议,通常只允许业务需要的 https:http: 或站内相对路径,不要让 javascript: 混进来。

const value = '" onblur="alert(1)"';
document.querySelector('#note').setAttribute('data-note', value);

不要把不可信数据拼进内联脚本、事件属性或 CSS。构造 URL 时可以用 URLSearchParams,但它只解决 URL 编码,不能把 innerHTML 变安全。

const params = new URLSearchParams({ data: userInput });
const url = `/search?${params.toString()}`;

富文本是例外,但也不是把 <script> 删掉就算完。事件属性、危险协议、SVG、样式和浏览器的兼容解析都可能绕过简单过滤。需要富文本时,用维护中的 sanitizer,并只放行实际需要的标签、属性和协议。保存前和展示前都应执行同一套规则,避免历史数据或其他写入路径漏网。

const clean = sanitize(dirtyHtml, {
  allowedTags: ['p', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code'],
  allowedAttributes: { a: ['href', 'title'] },
  allowedSchemes: ['https', 'http', 'mailto']
});

Content Security Policy 不能代替正确编码,但能在遗漏注入点时缩小脚本可做的事。它应该和实际资源来源一起逐步收紧,不要直接套一个会打断旧页面的模板。

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-<random-value>';
  object-src 'none';
  base-uri 'self';
  frame-ancestors 'none';

CSRF 和 CORS 不替 XSS 背锅

CSRF 利用的是浏览器自动附带凭据。用户已经登录目标站点时,攻击者诱导他访问另一个页面,那个页面就可能向目标站点发出改邮箱、转账或绑定设备的请求。攻击者未必能读到响应,但状态可能已经被改掉。

这和 XSS 不同。XSS 的脚本运行在目标页面里。CSRF 的脚本运行在攻击页面里,却借到了用户的 Cookie。状态变更接口应校验 CSRF token、OriginReferer,Cookie 要设置合适的 SameSite,敏感操作还应要求二次确认。具体组合取决于登录方式和跨站需求。

CORS 是浏览器的读取限制。https://app.example.comhttps://api.example.com 是不同源,即使它们属于同一家公司。前端跨源请求时,浏览器带上 Origin,服务端通过响应头决定这个源能不能读取结果。带自定义请求头、非简单 Content-Type 或特定方法的请求,还会先发预检。

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: Content-Type, Authorization

如果请求要带 Cookie,服务端还要明确允许凭据,并返回具体 Origin,不能使用 *

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

CORS 不做身份认证,也不会阻止攻击者用服务器、命令行或浏览器导航发请求。它限制的是浏览器里的脚本能不能读跨源响应。检查一个接口时,先看它有没有正确鉴权,再看 CORS 是否只放行了真正需要的来源。

发布于 2024 年 4 月 24 日
评论