上一节讲输入验证时,我们关注的是“这个值是否符合业务规则”。昵称有没有超长,数量是不是整数,状态值是否来自允许集合。到了 XSS,还得再问一句:这个值抵达浏览器后,会被当成文字、地址、样式,还是可执行的代码?
这两个问题听起来接近,解决的却不是同一层风险。一个长度完全合法的昵称,若被拼进 HTML 结构,照样可能改变页面;一段允许保存的富文本,若后来被塞进脚本字符串,也可能越过原来的安全边界。输入验证守住的是业务入口,XSS 防御守住的是浏览器的解释边界。
本节不会把 XSS 写成载荷大全。我们要练的是更耐用的能力:沿着数据流找来源和汇聚点,判断最终输出上下文,为不同上下文选不同防线,并在有书面授权的靶场里用最小、无害、可清理的证据完成验证。
本节的验证步骤只适用于本地靶场、隔离测试环境或明确写入授权范围的系统。不要向公开页面、真实用户内容区或第三方服务提交测试标记;不要读取会话信息、捕获键盘输入、触发业务操作或向外部位置发送数据。证明“数据被当成了结构”以后就应停止扩大影响。

开发者看见“昵称:小虎”,脑中想的是一段业务数据。浏览器看见的却是一串需要解析的字符。字符位于元素正文时,由 HTML 解析器处理;位于属性中时,还要遵守属性语法;位于链接中时,又进入 URL 语义;若出现在脚本或样式里,后续解析规则会继续变化。
所以,XSS 并不等于“页面里出现了某个标签”。更准确的理解是:应用原本想把外部数据交给浏览器显示,却让数据参与了页面结构或代码的构造。浏览器并没有做错,它只是忠实执行了应用交给它的文档。
我们可以把一次 XSS 风险拆成四段数据流:
只看入口会漏掉中间转换,只看页面现象又说不清根因。实际审计时,我更建议把“字段名”和“页面名”暂时放到一边,先画一条数据流。只要来源受外部影响,并且未经匹配处理就流入危险汇聚点,这条链路就值得继续检查。
浏览器解析 HTML 时会处理大小写、实体、嵌套结构、错误恢复和多种命名空间。前端代码还可能先解码一次,再把结果交给另一个解析器。用字符串替换去删除几个标签名,本质上是在猜浏览器最终会怎样理解内容。规则一多,正常输入容易被误伤;规则一漏,边界又重新打开。
真正稳定的思路是减少解释机会:纯文本就进入文本节点,普通属性就固定属性名并赋值,URL 就先解析再限制协议和目标,富文本才进入专门的 HTML 净化器。这样防御依赖的是清晰的类型和上下文,而不是一张永远补不完的关键词表。
这三种名称经常被讲成三类彼此独立的漏洞,其实它们主要是在回答“数据绕了哪条路”。最终判断标准仍然相同:外部可控数据是否进入了能够改变页面结构或执行代码的汇聚点。

搜索词、筛选条件、错误说明和跳转提示常常需要回显请求中的数据。如果服务器把某个参数拼入本次返回的 HTML,数据从请求进入响应,没有经过长期保存,这就是典型的反射路径。
“页面显示了我的输入”不是漏洞证据,因为安全编码后的输入也应该正常显示。真正需要记录的是它在原始响应中的精确位置:位于标签之间、带引号的属性值中,还是内联脚本的数据区域?到达浏览器以后,框架是否又读取并重新写入 DOM?同一个参数可能先经过服务器模板,再经过客户端渲染,因此“反射型”和“DOM 型”也可能出现在同一条链上。
评论、工单、个人资料、文章内容、客服备注会先被保存,稍后再出现在别的页面。风险不只取决于“能不能存进去”,还取决于谁会看到、在哪些页面看到、展示前又经过哪些处理。
例如,同一个工单标题可能出现在用户详情页、客服列表、管理报表和邮件预览里。四个输出点使用的组件不同,安全状态也可能不同。前台已经正确转义,并不能证明后台列表同样安全。测试存储路径时,需要先建立传播清单,把写入入口、记录标识、查看角色、输出页面、缓存与导出位置列清楚。
数据库保存原始内容也不自动等于漏洞。安全与否取决于每个输出点如何处理它。反过来,在入库前做过一次字符串清理也不代表以后安全,因为 Markdown 渲染、富文本编辑器、模板助手或二次解码都可能改变内容的含义。
DOM 型问题常见于脚本从 location.search、location.hash、document.referrer、window.name、postMessage、浏览器存储或已有 DOM 节点读取值,再把它交给 HTML 或脚本解析接口。初始 HTTP 响应里可能完全没有这段数据,所以只检查“查看网页源代码”很容易得出错误结论。
下面两种写法都想把搜索词放到结果区,但它们向浏览器表达的意图不同:
const keyword = new URLSearchParams(location.search).get('keyword') ?? '';
const result = document.querySelector('#result');
// 风险写法:要求浏览器把字符串重新解析为 HTML
result.innerHTML = keyword;
// 安全目标:明确要求浏览器创建一个文本节点
result.textContent = keyword;innerHTML、outerHTML、insertAdjacentHTML() 和 document.write() 会把字符串送入 HTML 解析过程。eval()、Function() 以及接收字符串代码的计时调用会把内容推向代码求值。审计中最划算的修改通常是删除这些危险汇聚点,让普通字符串根本没有机会被当成结构或代码。
分类只能帮助定位,不能代替数据流分析。报告不要停在“这是 DOM 型 XSS”,而应继续写清来源是什么、经过哪些转换、进入哪个汇聚点、最终处于哪一种浏览器上下文。
“统一转义所有输入”听上去很省事,实际上很容易制造假安全。HTML、属性、URL、JavaScript 和 CSS 使用不同语法,同一种编码不可能在所有位置都正确。编码还应该尽量靠近输出时执行,因为只有到那一步,我们才真正知道数据要去哪里。

当昵称、标题、搜索词只需要显示时,最自然的目标就是文本节点。服务器模板应使用默认插值,前端则优先使用 textContent、createTextNode() 或框架的普通文本绑定。它们表达的是“这是一段文字”,不需要应用自己猜测哪些字符危险。
const title = document.querySelector('#ticket-title');
title.textContent = ticket.subject;手写一套把 <、>、& 和引号替换成实体的函数,看似简单,却容易漏掉编码顺序、字符集和重复编码问题。已有框架或成熟编码库能完成的工作,不要另造一套局部规则。
普通属性可以安全承载数据,但前提是属性名由代码固定,属性值处于完整引号中,并且这个属性本身不会执行代码。data-*、title、id 等普通属性与事件处理属性不是同一风险等级。
const card = document.querySelector('#profile-card');
card.setAttribute('data-display-name', profile.displayName);如果外部数据能决定属性名,或者进入 onclick 一类事件属性,那么问题已经不再是“把引号转义好”这么简单。更合理的设计是删除动态属性能力,用 addEventListener() 绑定固定函数,并只把业务数据传给函数参数。
URL 同时有两层问题。第一层是结构:查询参数值要通过 URL API 放入正确位置,不能直接用字符串拼接。第二层是语义:完整地址要限制允许的协议,必要时还要限制来源、主机、端口和路径。一个语法完全合法的 URL,也可能不符合业务安全策略。
const target = new URL('/search', location.origin);
target.searchParams.set('keyword', keyword);
const sameOrigin = target.origin === location.origin;
const allowedScheme = ['https:', 'http:'].includes(target.protocol);
if (sameOrigin && allowedScheme) {
link.href = target.toString();
}如果这个地址随后被写入 HTML 属性,模板层仍要正确处理属性上下文。也就是说,URL 参数编码、URL 策略校验和 HTML 属性处理各自负责一层,不能拿其中一步冒充全部防线。
把外部数据拼进内联脚本、事件处理器、模板字面量、CSS 选择器或完整样式规则,会让编码规则非常脆弱。更好的结构是把数据通过 application/json 响应交给前端,确认响应类型正确,再由脚本使用安全 DOM API 展示。
样式定制也尽量使用有限枚举。例如用户可以选择“浅色”“深色”“高对比度”,应用只保存三个枚举值,前端再映射到三个预定义类名。这样既能满足业务需求,也避免把任意 CSS 当成配置保存。
标签名、属性名、HTML 注释、事件处理器、脚本源码、动态函数体和未经约束的 CSS 规则,都不适合作为外部数据的落点。遇到这些位置时,正确动作通常是重构接口或移动数据,而不是继续寻找一种更复杂的编码函数。
现代模板和前端框架普遍会在普通插值位置转义文本。React 的 JSX 文本表达式、Vue 的文本插值以及常见服务端模板,都在帮我们守住最常见的 HTML 正文边界。这是一个很实用的默认值:开发者写的是数据绑定,框架输出的是文本。
麻烦通常出在“逃生口”。为了渲染富文本,项目可能使用原始 HTML 指令、关闭自动转义、把字符串传给 innerHTML,或调用带有“信任”“安全”“原始”含义的绕过 API。这些能力不是漏洞,但它们把安全责任从框架重新交回给了业务代码。
代码审查时可以直接搜这些位置:
dangerouslySetInnerHTML;v-html;发现逃生口后,不要立刻把它一删了之。先确认业务到底要纯文本还是受限富文本。纯文本改回默认插值;富文本则明确允许哪些元素、属性和 URL 协议,再由专门净化器产出受控结果。框架升级后还要重新检查这些边界,因为组件、插件或渲染器的行为可能变化。
“框架会自动防 XSS”只在它的安全用法范围内成立。默认插值能保护普通文本,并不自动替你校验 URL,也不能保护被主动送入原始 HTML、内联脚本或第三方渲染器的数据。
这两个概念很容易混。输出编码适合“我只想显示用户输入的原样文字”。它让语法字符以字符的样子出现。HTML 净化适合“业务确实允许用户提供一小部分结构”,例如粗体、段落、列表和受限链接。它需要先解析结构,再按策略保留或移除节点和属性。
如果评论区不需要富文本,直接使用文本节点最简单。若业务允许有限格式,策略应该从最小集合开始:只开放真正需要的标签和属性,链接单独限制协议与目标,图片、媒体和嵌入内容默认不开放。维护良好的净化库还要跟随浏览器变化及时更新。

// 普通简介:始终按文本处理
bioNode.textContent = profile.bio;
// 富文本正文:先净化,再通过一个集中且可审查的入口渲染
const clean = sanitizer.sanitize(article.body, richTextPolicy);
renderSanitizedHtml(articleNode, clean);示例中的两个对象表达职责,不代表要手写净化器。项目应选择持续维护的实现,并把策略集中管理。净化完成后再拼接字符串、交给会改写内容的插件,可能重新破坏原有保证;因此净化之后到汇聚点之间的路径也要保持短而清楚。
很多团队会问:“既然展示时要编码,能不能入库时就编码一次?”通常不建议把某个输出上下文的编码永久写回通用数据。一个字段今天出现在 HTML 正文,明天可能进入 PDF、移动端、日志或 URL。提前按 HTML 编码会造成重复编码,也会把一个渠道的表示方式混进业务数据。
更容易维护的做法是保存规范化后的业务值,在每个输出点按目标上下文处理。富文本是例外场景之一:可以保存原始版本与经过策略净化的可发布版本,但两者要有清楚的类型和权限边界,不能让调用方随意混用。
对 DOM 代码来说,textContent、表单控件的 value、createTextNode() 和固定普通属性的赋值,通常比 HTML 字符串接口容易审计。重构时可以先统计危险汇聚点,再逐个替换。这样做的价值很直接:外部数据不再经过 HTML 解析器,许多绕过问题也随之消失。
XSS 测试不需要一上来就执行脚本。大多数情况下,只要证明“本应显示为文字的内容被浏览器解析成了结构”,就足以定位缺失的边界。证据越小,越容易清理,也越方便开发者复测。
先确认授权。记录允许的域名、环境、账号、角色、功能路径、测试时间窗、禁止动作和紧急联系人。存储型测试还要明确哪些查看端在范围内,以及由谁清理测试记录。
给每个入口生成唯一的纯文本标记,例如 XSS-CHECK-4821。先不带任何 HTML 语法,观察它是否被接收、保存、解码和展示,并记录请求、响应、记录编号、页面路径与账号角色。
同时检查原始响应和脚本运行后的 DOM。确认标记处于元素正文、属性、URL、脚本数据还是样式中;若页面由前端渲染,再沿调用栈找到读取来源和写入汇聚点的位置。
只有在隔离靶场且授权允许时,使用不含事件与脚本的可视标记,例如一个带唯一测试属性的加粗文字。若预期纯文本却生成了真实元素,解释边界已经失效,此时停止继续扩大验证。
至少记录输入参数、唯一标记、原始响应中的位置、浏览器最终 DOM、输出上下文、使用的模板或组件,以及修复前后的对照。只写“搜索页存在 XSS”无法指导修复,因为开发者不知道是哪次插值、哪种上下文或哪个二次渲染步骤出了问题。
存储内容可能被异步任务、搜索索引、通知预览和后台页面继续传播。验证前先列出传播面,验证后按清单回收。若某个高权限查看端不在授权范围内,就由系统负责人安排隔离账号或在测试副本中复现,不要诱导真实管理员打开测试记录。
地址栏片段等数据可能根本不会发送给服务器,却会被前端脚本读取。断点、调用栈和 DOM 变化记录能说明数据从哪里来、何时被处理、最后去了哪里。初始响应里找不到标记,只能说明服务器没有直接返回它,不能据此判定客户端安全。
内容安全策略可以限制页面允许加载和执行的资源,阻止未经许可的内联脚本或动态代码执行。严格策略通常使用每次响应随机生成的 nonce,或为固定脚本计算哈希,再结合 strict-dynamic 建立脚本信任关系。
但 CSP 不会把 innerHTML 自动改成 textContent,也不会替富文本选择正确的净化策略。浏览器兼容、第三方脚本、过宽来源、错误的 nonce 复用和临时加入的宽松指令,都可能削弱效果。代码层的安全汇聚点、上下文编码和净化仍是第一道门,CSP 负责在它们失手时缩小影响。

Content-Security-Policy-Report-Only:
default-src 'none';
script-src 'nonce-每次响应随机值' 'strict-dynamic';
style-src 'self';
img-src 'self';
connect-src 'self';
base-uri 'none';
object-src 'none';
frame-ancestors 'none'这只是教学骨架,不是可直接复制的生产配置。真实项目要先盘点脚本、样式、图片、接口、字体、工作线程和第三方服务,再决定每条指令。nonce 必须由服务端为每次响应安全生成,只放在可信脚本元素上,不能从请求参数或页面内容继承。
一次性强制启用严格策略,往往先打断正常功能。更稳妥的部署节奏是:盘点资源,设置报告模式,观察合法页面触发的违反记录,移除内联脚本和动态求值依赖,补齐自动化测试,再切换到强制模式。
报告也需要安全治理。它可能带有页面路径、被阻止的资源信息和少量代码样本。接收端应限制访问,进行字段脱敏和容量控制,设置留存期,并对突然增多的同类事件做聚合,而不是把每条报告原样永久保存。
大型前端项目可能散落着大量 HTML 注入接口。Trusted Types 可以通过 CSP 要求一组 DOM 注入汇聚点不再接受普通字符串,只接受由指定策略创建的可信类型。这样,危险写入会收敛到少数可审查的策略入口,也更容易在报告模式中发现遗留调用。
类型本身不负责净化。如果策略函数只是原样返回输入,那么它只是在给不安全内容盖章。正确做法是限制可创建的策略名称,在策略内部调用可靠净化逻辑,并保留安全汇聚点、输出编码和 CSP 的其他约束。上线前还要按目标浏览器矩阵验证支持与降级行为。
可以把防御顺序记成一句话:先让数据进入正确的安全汇聚点;确实需要富文本时才做结构化净化;最后用严格 CSP 与 Trusted Types 限制漏网路径。顺序反过来,往往会把纵深防线误当成根治方案。
“修复后没有弹窗”不是一个可靠的安全不变量。它可能只是某个字符串被拦截,也可能是浏览器策略恰好阻止了现象。更有价值的断言是:外部数据始终停在预期上下文;富文本只保留允许结构;危险 DOM 汇聚点不再接收普通字符串;CSP 在最终响应链路生效。
服务端日志可以记录请求标识、路由、字段名、处理结果、模板或组件版本、账号角色、策略版本和时间。客户端可以聚合 CSP 违反类型、被阻止指令、页面类别与发布版本。这样发生异常时,我们能把入口、转换和输出位置串起来。
不要为了排查 XSS 把完整输入、会话令牌、认证头或页面中的个人数据全部写进日志。对测试标记可以记录唯一编号,对普通用户内容则优先记录长度、类型、规则命中和不可逆摘要。日志查看权限、保存期限与导出渠道也要纳入威胁模型,因为后台日志页面本身同样需要安全输出。
负向用例确认数据不能改变结构,正向用例确认正常功能没有被“修复”掉。中文、引号、尖括号文字、表情符号、合法查询参数和允许的富文本格式都应正常工作。否则团队可能通过粗暴删除字符得到一个表面安全、实际不可用的页面。
同一个字段若出现在正文、属性、站内链接、邮件预览与后台报表中,就应为每个上下文建立独立用例。模板或前端组件发生变化后,这些用例跟着运行。第三方渲染器、净化器和框架升级也要触发定向回归,因为解析行为与安全修复会随着版本变化。
仓库里存在 CSP 配置,不等于浏览器收到的最终响应一定正确。反向代理、缓存层、错误页、重定向与子域可能使用不同头部。验证时应覆盖真实访问链路中的正常页面、错误页面与关键入口,并确认报告模式和强制模式的行为符合预期。
一条可交付的发现记录应包括:授权环境、入口、唯一标记、数据路径、输出上下文、最小无害现象、受影响角色、修复建议、复测结果与清理确认。它让开发者可以复现,让测试者可以验证关闭,也不会为了展示风险而收集超出目标的数据。

这一节把注意力放在“浏览器会怎样解释数据”。下一节的 CSRF 会换一个方向:页面未必错误解析了外部内容,但浏览器可能自动携带登录状态,替用户发出并非本人意愿的请求。两类问题机制不同,分析习惯却相通——先画清信任边界和数据流,再用最小证据验证,最后把修复写成可重复检查的工程规则。
根据最终上下文提出修复。优先改成安全汇聚点,其次恢复模板默认转义;只有业务允许富文本时才启用严格净化。URL、脚本与样式上下文要分别处理,不能套用 HTML 正文编码。
在同一环境、同一角色与同一路径复测。正常中文、引号、尖括号文本和合法富文本仍应正确显示,而无害测试标记不能改变预期边界。最后删除测试记录并确认缓存、索引、导出与审核队列没有残留。