技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTETC

前端安全系列(一):如何防止 XSS 攻击?

所属馆藏
Other
文件格式
Markdown
原始路径
Other/11-前端安全系列(一):如何防止XSS攻击?
本文目录13 个章节
  1. 一、先纠正两个常见误区
  2. 二、XSS 是什么
  3. 三、一个反射型 XSS 例子
  4. 四、HTML 转义不能替代 URL 校验
  5. 五、根据输出上下文选择防御
  6. 六、内联 JSON 也需要安全处理
  7. 七、XSS 的三种类型
  8. 八、不要把输入校验误当作 XSS 的主防线
  9. 九、纵深防御
  10. 十、如何检测 XSS
  11. 十一、历史案例应该如何阅读
  12. 十二、总结
  13. 参考资料

前端安全系列(一):如何防止 XSS 攻击?

Category(分类): Other Status: 持续更新

原文参考:美团技术团队:前端安全系列(一)——如何防止 XSS 攻击?

XSS 是前端最常见、也最容易被“一个全局过滤函数”误导的问题之一。本文保留原文中“根据上下文处理输出”的主线,补充现代框架、CSP、Trusted Types 和安全测试的实践。

示例只用于本地或授权测试环境。不要把会执行脚本的 Payload 投放到生产站点,也不要测试不属于自己的系统。

一、先纠正两个常见误区

误区 1:XSS 只是后端的责任

不正确。存储型和反射型 XSS 经常由服务端模板输出不安全数据造成,DOM 型 XSS 则可能完全发生在浏览器端。输入校验、服务端输出、前端 DOM 操作、第三方组件和安全响应头都需要共同负责。

误区 2:所有数据统一调用一个过滤函数就安全

不正确。数据进入 HTML 文本、HTML 属性、URL、JavaScript、CSS 和富文本时,浏览器解析规则不同,编码策略也不同。HTML 文本编码不能直接替代 JavaScript 编码,也不能把一个危险 URL 变成安全 URL。

更可靠的原则是:

默认把外部数据当作不可信;优先使用框架自动转义和安全 DOM API;必须进入特殊上下文时,使用与该上下文匹配的编码、校验或清洗。

二、XSS 是什么

XSS(Cross-Site Scripting,跨站脚本攻击)是指攻击者让不可信数据在受害者浏览器中作为 HTML、JavaScript、URL 或其他可执行内容处理。

XSS 成功后可能导致:

  • 读取页面中的个人信息、表单和非 HttpOnly Cookie;
  • 以当前用户身份调用同源接口;
  • 修改页面、插入钓鱼表单或跳转到恶意站点;
  • 读取 localStoragesessionStorage 和运行时令牌;
  • 在评论、私信等功能中传播,形成 XSS 蠕虫。

HttpOnly Cookie 可以阻止脚本通过 document.cookie 读取某个 Cookie,但不能阻止 XSS 代表用户发起请求,因此它只能降低部分影响,不能代替修复漏洞。

三、一个反射型 XSS 例子

假设服务端把 URL 中的 keyword 直接拼进模板:

<!-- 危险伪代码:raw 表示未经转义的插值 -->
<input type="text" value="{{ raw(keyword) }}">
<div>您搜索的关键词是:{{ raw(keyword) }}</div>

如果模板引擎没有自动转义,攻击者可以构造包含引号、标签或事件属性的输入,改变原本的 HTML 结构。浏览器不会知道这段内容是“恶意的”,它只会按 HTML 语法解析。

安全输出应使用模板引擎的默认转义:

<!-- 伪代码:keyword 必须经过 HTML 转义,不能使用 raw 插值 -->
<input type="text" value="{{ keyword }}">
<div>您搜索的关键词是:{{ keyword }}</div>

对于不需要 HTML 的内容,转义后应表现为普通文本。常见的 HTML 实体包括:

字符HTML 表示
&&amp;
<&lt;
>&gt;
"&quot;
'&#x27;

这些规则适合 HTML 文本和普通属性值,但不适合直接解决所有输出上下文。

Vue、React 等框架

现代框架的普通插值通常会把值当作文本处理,例如 Vue 模板中的:

<template>
  <p>{{ keyword }}</p>
</template>

但以下 API 会绕过默认保护,必须单独审查:

  • Vue 的 v-html
  • React 的 dangerouslySetInnerHTML
  • Angular 的 bypassSecurityTrust...
  • Lit 的 unsafeHTML
  • 直接使用 innerHTMLouterHTMLinsertAdjacentHTML

框架减少了漏洞数量,但不会自动修复错误的 URL、第三方 HTML、服务端模板或不安全的逃生口。

四、HTML 转义不能替代 URL 校验

下面的跳转地址即使进行了 HTML 属性编码,也可能仍然是危险协议:

<a href="{{ redirectTo }}">跳转</a>

如果 redirectTo 的值是 javascript:...,浏览器可能在用户点击时把它当成脚本协议。不能只检查一个大小写敏感的字符串前缀,因为大小写、空白、URL 解析和编码都可能影响结果。

如果业务只允许站内跳转,可以限制到当前源:

function safeInternalUrl(input) {
  try {
    const url = new URL(input, window.location.origin)

    if (url.origin !== window.location.origin) {
      return '/404'
    }

    return url.href
  } catch {
    return '/404'
  }
}

link.href = safeInternalUrl(userInput)

如果业务必须允许外部链接,应使用精确的协议和主机白名单,并考虑开放重定向、钓鱼和 data: 等协议。encodeURIComponent() 只负责编码 URL 的组成部分,不负责判断 URL 是否可信。

五、根据输出上下文选择防御

上下文推荐做法不要做什么
HTML 文本框架自动转义或 HTML 实体编码直接拼接 innerHTML
普通 HTML 属性固定属性名、引号包裹、属性编码让用户控制属性名或 on* 属性
URL 参数对参数值做 URL 编码把整个 URL 盲目编码或只替换 javascript:
href / src先解析 URL 并限制协议/来源,再赋值直接使用不可信完整 URL
JavaScript尽量不把数据拼进脚本,改用 JSON 数据节点把字符串直接拼进 <script>
CSS使用固定属性和结构化值拼接选择器、样式代码或不可信 CSS URL
富文本使用维护中的 Sanitizer 和白名单只删除 <script> 标签

危险上下文包括内联事件、脚本块、HTML 注释、动态标签名、CSS 选择器和任意 JavaScript 表达式。很多场景下,最安全的做法不是“寻找更复杂的转义”,而是改变数据流,避免把变量放入危险上下文。

六、内联 JSON 也需要安全处理

有些服务端页面会把初始化数据放进 HTML:

<script>
  window.initialState = {{ data.toJSON() }}
</script>

如果 JSON 字符串包含 </script>,HTML 解析器可能提前结束当前脚本;U+2028U+2029 等字符在旧 JavaScript 语境中也可能造成语法问题。不能把普通 HTML 转义和 JavaScript/JSON 编码混为一谈。

更容易控制的方式是使用 JSON 数据节点,并由服务端序列化后对 < 等字符做安全处理:

<script id="initial-state" type="application/json">
  {"title":"示例"}
</script>
<script>
  const raw = document.querySelector('#initial-state').textContent
  const initialState = JSON.parse(raw)
</script>

服务端应使用成熟序列化库,并确保输出到 HTML 的 JSON 不会产生 </script>。示意性的字符处理通常包括将 <>&U+2028U+2029 表示成 Unicode 转义;不要在业务代码里手写一套不完整的转义规则。

七、XSS 的三种类型

1. 存储型 XSS

恶意内容被保存到数据库、缓存、评论、昵称、帖子、私信或工单中,其他用户访问页面时触发。

防护重点:

  1. 对长度、类型、格式和业务范围做服务端输入校验;
  2. 普通文本在输出时按上下文编码;
  3. 业务确实需要富文本时,使用成熟 HTML Sanitizer;
  4. 清洗后不要再次拼接不可信 HTML;
  5. 前端校验只能改善体验,不能代替服务端校验。

输入过滤不能简单地替代输出编码。数据可能会在多个页面、接口、日志和管理后台中以不同上下文输出,提前把所有 < 改成 &lt; 可能造成乱码或双重编码。

2. 反射型 XSS

恶意数据通常来自查询参数、表单或请求头,服务端把它直接反射到当前响应。搜索、错误提示、跳转和预填表单是常见位置。

修复时应在最终输出点使用上下文匹配的编码,并检查所有响应模板,而不是只在某一个接口入口加过滤器。

3. DOM 型 XSS

恶意数据可能来自:

  • location.hreflocation.searchlocation.hash
  • document.referrerwindow.namepostMessage
  • localStoragesessionStorage 和接口响应。

常见危险接收器包括:

// 危险:把不可信字符串当作 HTML 解析
container.innerHTML = value
container.insertAdjacentHTML('beforeend', value)
document.write(value)

// 危险:把字符串当作代码执行
eval(value)
new Function(value)
setTimeout(value, 0)

文本场景应使用:

container.textContent = value
input.value = value

如果必须展示 HTML,先用 DOMPurify 等维护中的 Sanitizer 清洗,并控制允许的标签、属性和 URL 协议:

const clean = DOMPurify.sanitize(untrustedHtml)
container.innerHTML = clean

清洗库需要持续升级,且清洗后的内容不能再交给会修改或重新解释它的库。

八、不要把输入校验误当作 XSS 的主防线

原文讨论了“前端过滤”和“入库前过滤”。正确的分工是:

  • 输入校验:限制数据类型、长度、枚举、格式和业务范围;
  • 输出编码:在确定输出上下文后,把数据当作数据展示;
  • HTML 清洗:业务明确允许富文本时,删除危险节点、属性和 URL;
  • CSP/Trusted Types:作为浏览器侧的额外限制层;
  • 服务端授权:防止用户绕过前端直接调用接口。

不能指望一个请求拦截器知道某个参数最终会进入 HTML、CSS 还是 JavaScript。全局过滤器容易漏掉响应中的数据库数据、请求头和 DOM 型来源,也可能导致双重编码。

九、纵深防御

1. CSP

CSP 可以限制脚本来源、内联脚本和外部提交,降低 XSS 成功后的影响。示意:

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

<BASE64_NONCE> 只是占位符,服务端必须为每次响应生成不可预测的 nonce,并把同一个 nonce 放到允许执行的脚本标签上;不能原样复制。建议先使用 Content-Security-Policy-Report-Only 观察兼容性,再逐步收紧策略。

CSP 是纵深防御,不能替代输出编码。不要为了让旧代码“先跑起来”长期依赖 'unsafe-inline''unsafe-eval'

2. Trusted Types

在支持的 Chromium 环境中,可以评估:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types default

它可以让 innerHTML 等危险接收器拒绝普通字符串,迫使代码通过经过审查的 Trusted Types 策略。它不是所有浏览器都支持,也不是清洗和编码的替代方案。

敏感会话 Cookie 可以设置:

Set-Cookie: __Host-session=随机值; Path=/; Secure; HttpOnly; SameSite=Lax

不要把长期会话令牌和敏感数据放在 localStorage。Cookie 属性只能降低部分影响,发生 XSS 后,恶意代码仍可能在当前会话中调用接口。

4. 第三方依赖和上传内容

第三方脚本会在当前页面权限下执行,应审核来源、版本、变更和权限。静态内容在适用时使用 SRI,并通过 CSP 限制脚本来源。用户上传的 HTML、SVG、脚本和可执行文件应隔离、下载或清洗,不能仅通过文件扩展名判断安全。

十、如何检测 XSS

1. 代码审查

从输入源追踪到 DOM 或模板接收器:

  • 搜索 innerHTMLouterHTMLinsertAdjacentHTMLdocument.write
  • 搜索 evalnew Function、字符串定时器和动态脚本;
  • 检查 hrefsrcstyle 和事件属性的来源;
  • 检查 v-htmldangerouslySetInnerHTML 等框架逃生口;
  • 检查接口返回值、缓存和 Web Storage 是否被当成可信 HTML。

2. 授权环境测试

在测试环境中使用无害的唯一标记,确认它被当作文本还是 HTML;对需要执行验证的场景使用短、可控、可回收的测试标记,并确保不会把数据发送给外部地址。不要在生产环境直接提交网上流传的 XSS Polyglot。

自动化工具只能辅助发现问题,DOM 型 XSS 和业务上下文仍需要人工复核。可以参考:

Arachni、w3af 和旧版 Mozilla Observatory 可以作为历史资料或特定环境工具,但维护状态和能力需要单独确认;HTTP Observatory 主要检查响应头,不能替代应用漏洞扫描。

十一、历史案例应该如何阅读

原文列举过 QQ 邮箱、新浪微博等历史反射型 XSS 案例,它们说明了:

  • 公开网站也可能出现 XSS;
  • 一个 URL 参数就可能影响多个输出位置;
  • XSS 不只会弹窗,还可能修改页面、调用接口和传播;
  • 不能因为案例已经修复,就把其中的域名和 Payload 复制到现实系统测试。

这些案例只适合帮助理解漏洞成因。今天的验证应使用授权测试环境、无害标记和现代工具。

十二、总结

防止 XSS 的关键不是寻找一个“万能过滤函数”,而是保持数据和代码的边界:

  1. 默认不信任用户、接口、第三方和浏览器存储中的数据;
  2. 优先使用框架自动转义和安全 DOM API;
  3. 根据 HTML、属性、URL、JavaScript、CSS 等上下文选择处理方式;
  4. 富文本使用成熟 Sanitizer,不能只删除 <script>
  5. 避免 innerHTML、事件属性、动态脚本和字符串求值;
  6. 用 CSP、Trusted Types、Cookie 属性和依赖治理降低影响;
  7. 通过代码审查、测试、扫描和监控持续发现问题。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS