name: stored-xss-detection description: >- 存储型 XSS 白盒数据流审计——按"用户输入 → 持久层(DB / 文件 / Redis)→ 回读 → 模板 / 前端 输出"跨持久层介质追 source-sink 可达性,识别 encoder 与输出上下文(HTML body / attr / JS / URL / CSS)不匹配、富文本白名单缺口、Markdown 渲染保留原始 HTML、二阶回读路径漏 sanitize。 when-to-use: 当需要专门分析“用户输入 -> 持久化 -> 二次展示”链路,或者普通 XSS sink 规则不足以确认高危 XSS 时 allowed-tools: bash,read_file,list_files,rg,list_skills user-invocable: true argument-hint: "[target_path]" arguments: - target_path
存储型 XSS 白盒数据流审计
1. 触发线索 / 适用信号
按"输入字段命名 + 模板引擎 + DB 表字段"三维识别本能力命中场景(不按业务命名)。
代码 pattern 维度(grep 命中模式):
- 用户字段写入:
INSERT INTO/UPDATE接comment/title/description/nickname/bio/content/remark/name列 - 模板 raw 输出:
th:utext/<#noautoesc>/?no_esc/{{ var|safe }}/<%- %>/v-html/dangerouslySetInnerHTML/innerHTML\s*= - 富文本/Markdown:
marked(/markdown-it/Quill/TipTap/wangEditor/tinymce - SSR 直写:
response.write(html)/res.send('<...>'+ ...)/template.HTML(...)(Go)
文件结构 / 命名约定维度:
src/main/resources/templates/(Spring + Thymeleaf)/WEB-INF/jsp// Freemarker.ftl- Django
templates/*.html+views.py - Express
views/*.ejs/*.pug/*.hbs - Vue
*.vueSFC、React*.tsx/*.jsxSSR 入口(server.tsx/entry-server.ts) *Mapper.xml里INSERT/UPDATE用户字段;ORMmodels/含富文本字段类型
依赖 / 注解维度:
pom.xml含thymeleaf/freemarker/velocity/owasp-java-html-sanitizerpackage.json含dompurify/sanitize-html/marked/markdown-it/vue/react-dom/server/ejsrequirements.txt含django/jinja2/bleach/markdowngo.mod含html/template(安全默认)vstext/template(无 escape)
反向信号(不命中本能力):
- source 只是 URL query 直接回显(属反射型 XSS / dom-xss)
- 输入未经任何持久层介质,纯单请求生命周期(属反射 / DOM)
- 输出位置纯文本(
{{ var }}auto-escape 默认、textContent、th:text)且无 raw 旁路
2. 造成原因(共享章节)
source 是任何用户可控输入(HTTP 参数 / 上传文件内容 / 文件名与 meta / 第三方回调字段),sink 是模板或前端的 HTML 渲染上下文:HTML body / attribute / JavaScript / URL(href / src)/ CSS / SVG / Markdown 原生 HTML。与反射型 XSS 的根本区别在于 source 经持久层介质(DB / 文件系统 / Redis / 对象存储 / 消息队列),跨请求 / 跨用户生命周期触发。
任何 source 入库时未做 HTML 转义、回读到 sink 时 encoder 不匹配输出上下文,即构成存储型 XSS——浏览器解析时把数据当作脚本指令执行。安全的默认形态有两条路径:(a)入库前用白名单 sanitizer(如 OWASP Java HTML Sanitizer / DOMPurify / bleach / bluemonday)把内容净化为受控 HTML;(b)输出时按上下文走匹配 encoder(HTML body 用 < 编码、attribute 用 quote+entity、JS 上下文用 \xHH、URL 上下文校验协议白名单)。
encoder 必须匹配输出上下文:HTML entity encoding 救不了 <script> 内的 JS 上下文,URL encoding 救不了 javascript: 协议注入。富文本场景下白名单必须覆盖标签 + 属性 + URL 协议三层,缺一即被绕过。
3. 领域 source-sink 数据流模型
代码层 source 集合(按持久层介质):
- DB 字段:用户输入入库的列(
comment.content/user.bio/article.body/document.title);任何INSERT/UPDATE端点写入的列即潜在 source - 文件内容 / 文件名 / meta:上传后落盘的 SVG / HTML / Markdown / Office 内容;文件名进 DB
- Redis / KV 缓存:会话级用户数据缓存、富文本编辑稿缓存
- 二阶 source:已入库字段被另一段 SQL 回读后再入新表的中继场景
- 跨服务回调:MQ / RPC / Webhook 写入的字段被前台展示
代码层 sink 集合(按输出上下文):
- 服务端模板 raw 输出:
- Thymeleaf
th:utext="${var}"/[(${var})]非转义语法 - Freemarker
<#noautoesc>...${var}...</#noautoesc>/${var?no_esc} - Django
{{ var|safe }}/{% autoescape off %} - Jinja2
{{ var|safe }}/Markup(...) - Velocity
$!{var}在 strict-ref 关闭时 - Go
text/template全部输出 /html/template中template.HTML(var)强类型旁路
- Thymeleaf
- 前端框架 raw 渲染:
- Vue
v-html="var"/ 手动el.innerHTML = ... - React
dangerouslySetInnerHTML={{ __html: var }} - Angular
[innerHTML]="var"(默认走 DomSanitizer,但bypassSecurityTrustHtml会旁路)
- Vue
- JS DOM API:
innerHTML/outerHTML/insertAdjacentHTML/document.write/eval/new Function - SSR 直写:
response.write(string)/res.send(htmlString)/ Servletout.print(string) - Markdown 渲染器 raw HTML 通道:
marked({ sanitize: false })/markdown-it({ html: true })/commonmark默认允许原始 HTML - 富文本编辑器回显:Quill Delta → HTML、TipTap JSON → HTML 时白名单缺失
数据流追踪规则:
- 跨持久层追踪:列出所有写入字段 X 的端点 → 列出所有回读字段 X 的端点 → 写读交叉集合即候选;任一回读路径用 raw sink 即真洞
- 跨服务边界:本服务范围内追到出站调用即停,下游服务由对应实例独立审计
- sanitize 位置识别:入库前 sanitize(防一阶)vs 回读后 sanitize(防二阶);同字段两处都做才能算"全链路覆盖"
- encoder 上下文匹配:模板默认 auto-escape 是 HTML body 上下文的 encoder,不适用 attribute 无引号 / JS 嵌入 / URL 协议位置
- 闭源依赖:自定义 sanitizer / 闭源富文本编辑器后端规则不可见时入 §11 静态分析边界
4. 常见类型(共享章节)
| 类型 | 输出上下文 / 触发位置 | 关键识别特征 |
|---|---|---|
| HTML body 上下文 XSS | <div>{{var}}</div> 走 raw 输出 |
sink 直接渲染 <script> / <img onerror> 等标签 |
| Attribute 上下文 XSS | <a title="{{var}}"> 缺引号 / 引号未转义 |
单/双引号可破出属性边界 |
| JS 上下文 XSS | <script>var x = '{{var}}'</script> |
嵌入到 <script> 内,需 JS 字符串 escape |
| URL 上下文 XSS | <a href="{{var}}"> / <img src="{{var}}"> |
javascript: / data:text/html 协议未过滤 |
| CSS 上下文 XSS | <style>...{{var}}...</style> / style="..." |
expression() / url(javascript:...) |
| SVG 上下文 XSS | 上传 SVG 在线预览 / dangerouslySetInnerHTML 渲染 SVG |
SVG 内 <script> / onload 可执行 |
| Mutation XSS(mXSS) | DOMPurify 默认配置经 innerHTML 解析后变异 | 浏览器 HTML parser 兼容性导致清洗后 DOM 变形 |
| 富文本白名单绕过 | Quill / TipTap / wangEditor 后端 sanitize 不覆盖 formaction / srcset 等冷门属性 |
白名单只覆盖常见标签属性,遗漏冷门触发 |
| Markdown 原生 HTML 注入 | marked / markdown-it html: true |
Markdown 中含原始 HTML 标签被保留 |
| 二阶回读漏 sanitize | A 端点 sanitize 入库、B 端点不再做 / 用错 encoder | "已审过 → 不再 sanitize" 的二次路径 |
5. 入口点定位
按项目结构同时找写入点(INSERT/UPDATE)和回读输出点(模板 / 列表 / 详情接口)。
下列框架 / 项目类型仅作类似项目示例,不限于此;以目标实际栈为准。
| 栈 | 写入点典型位置 | 回读 / 输出点典型位置 |
|---|---|---|
| Java / Spring + Thymeleaf | @PostMapping/@PutMapping → *Service → *Repository.save() / *Mapper.xml INSERT |
src/main/resources/templates/*.html 找 th:utext / [( ... )];JSON 返回 + 前端 Vue/React v-html |
| Java + JSP / Freemarker | 同上 | WEB-INF/jsp/*.jsp 找 <%= %> 无 fn:escapeXml;.ftl 找 <#noautoesc> / ?no_esc |
| Python / Django | views.py POST handler → Model.objects.create() / .save() |
templates/*.html 找 {{ var|safe }} / {% autoescape off %} |
| Python / Flask + Jinja2 | request.form/json → SQLAlchemy session.add(...).commit() |
templates/*.html 找 {{ var|safe }} / Markup(...) / render_template_string(...) |
| Node.js / Express + EJS/Pug/HBS | req.body → Sequelize/TypeORM/mongoose .create() / .save() |
views/*.ejs 找 <%- var %>;Pug !{var};Handlebars {{{var}}} |
| Vue SSR | API 端点 | *.vue 找 v-html;entry-server.ts 渲染管线手写 innerHTML |
| React SSR | API 端点 | *.tsx / *.jsx 找 dangerouslySetInnerHTML;renderToString 入口 |
| Go / Gin + html/template | handler → Gorm db.Create(...) |
.tmpl / .html 模板;template.HTML(s) 强类型旁路;text/template 全无转义 |
| PHP / Laravel + Blade | Controller → Eloquent Model::create() |
Blade {!! $var !!}(raw)vs {{ $var }} |
通用建议:主路径直接读代码——路由文件(Spring 注解 / Express routes/ / Django urls.py / Gin router.go)作为入口锚点反向追 sink 候选,rg 上表的 raw 输出 / 写入点 pattern 自盘点;用 dataflow-analysis 跨函数追写入字段在哪些查询/接口被回读;若已有 sast-scan 候选可消费其模板 raw 输出 sink 与 ORM 写入点条目加速(可选,缺位 / 空命中不阻塞,直接走上述 grep);闭源富文本 / 闭源 sanitizer 见 §11 静态分析边界。
6. 跨框架代码变体
下表覆盖主流模板引擎 / 前端框架的"安全形态 vs 危险形态"对照。
| 框架 / 上下文 | 安全形态(escape) | 危险形态(raw) |
|---|---|---|
| Thymeleaf | th:text="${var}"(auto-escape) |
th:utext="${var}" / [(${var})] |
| Freemarker | auto-escape ON(<#ftl auto_esc=true>) / ${var?html} |
<#noautoesc>${var}</#noautoesc> / ${var?no_esc} |
| JSP | <c:out value="${var}"/> / fn:escapeXml(var) |
<%= var %> / ${var} EL 直出 |
| Velocity | strict-ref + 显式 escape tool | $!{var} / $var 默认无转义 |
| Django templates | {{ var }}(auto-escape) |
`{{ var |
| Jinja2 | {{ var }}(auto-escape,需 env 启用) |
`{{ var |
| Vue | {{ var }} / :text="var" / v-text |
v-html="var" / 手写 el.innerHTML = var |
| React | {var} JSX 默认 escape |
dangerouslySetInnerHTML={{__html: var}} |
| Angular | {{ var }} / 默认 [innerHTML] 走 DomSanitizer |
bypassSecurityTrustHtml(var) / [innerHTML] 后传入 trusted |
| EJS | <%= var %>(escaped) |
<%- var %> |
| Pug | #{var} / =var(escaped) |
!{var} / !=var |
| Handlebars | {{var}}(escaped) |
{{{var}}} / triple-stash |
| Mustache | {{var}} |
{{{var}}} / {{&var}} |
| Blade(Laravel) | {{ $var }} |
{!! $var !!} |
| Go html/template | 默认按上下文 auto-escape | template.HTML(s) / 切换到 text/template |
| JS DOM | el.textContent = var / el.setAttribute('title', var) |
el.innerHTML = var / insertAdjacentHTML / document.write |
| Markdown / marked | marked(src, { sanitize: true })(旧版)/ 配 DOMPurify 后处理 |
marked(src) 默认保留原始 HTML(v4+ 移除 sanitize 必须外挂) |
| Markdown / markdown-it | MarkdownIt({ html: false }) 默认 |
MarkdownIt({ html: true }) |
富文本 / sanitizer 特殊位:
- DOMPurify:默认配置可能被 mXSS 绕过;
ADD_TAGS/ADD_ATTR扩白名单时需评估 - OWASP Java HTML Sanitizer:使用
Sanitizers.FORMATTING.and(Sanitizers.LINKS)等链式 policy;遗漏 policy 即不防对应类 - bleach(Python):默认
ALLOWED_TAGS不含style/iframe,但允许的属性里若有style仍可注入 CSS XSS - bluemonday(Go):
UGCPolicy()是 user-generated content 默认策略;自定义 policy 易遗漏 URL scheme 校验
7. 思考检查点(共享章节)
加载本 skill 时按这些问题思考:
- 这个用户字段从写入端点入库,被哪些 SELECT 回读?每个回读出口的输出位置(HTML body / attr / JS / URL / CSS)encoder 是否匹配?
- 是不是所有写入路径都做了 sanitize?只要一处遗漏,回读 sink 是 raw 输出即真洞——同字段多写入端点要全部覆盖。
- 富文本白名单是否真覆盖标签 + 属性 + URL 协议三层?
onerror/srcset/formaction/data:/javascript:协议遗漏即被绕过。 - Markdown 渲染器是否启用 sanitize?
markedv4+ 已移除内建 sanitize 选项,必须外挂 DOMPurify;markdown-it默认html: false但项目可能改true。 - 是不是二阶?"已审过 → 不再 sanitize" 的二次回读路径常见——同字段在 SELECT 后被拼进新 HTML 上下文(如复制到通知模板、邮件 HTML、导出 PDF)。
- 是否有跨用户角色的差异渲染链——前台 sanitize、管理后台为"运营方便"用 raw 输出?
8. 检测方法论 / 数据流追踪
本能力只到 static-confirmed——动态利用证据走对应黑盒 / graybox skill。本节方法论描述白盒数据流追踪,不规定 plan / step 编排。
Step 0:基线侦察
- 加载 project-framework-analysis 输出,识别 web 框架 / 模板引擎 / 前端框架 / ORM / sanitizer 库及版本
- 列出本项目持久层模型所有字段(
models//*Mapper.xml/entity/),用户可控字段集合作为 source 候选 - 已有 sast-scan 输出时可消费其 high_noise_patterns + needs_dataflow_confirmation 桶里 XSS 相关条目加速(可选,缺位 / 空命中不阻塞,直接走 Step 1 grep)
Step 1:grep 出所有写入点
# ORM 写入
rg -n '\.(Create|Save|Update|Updates|insert|create|save|upsert)\s*\(' <target_path>
# 原生 SQL 写入
rg -n -i 'INSERT INTO|UPDATE\s+\w+\s+SET' <target_path>
# MyBatis XML 写入
rg -n -i '<insert\b|<update\b' --type xml <target_path>
# 上传落盘
rg -n '(PutObject|WriteFile|writeFile|fs\.write|os\.WriteFile)' <target_path>
把命中按"写入字段"分类,得到字段级别的写入点清单。
Step 2:grep 出所有 raw 输出 sink
# 服务端模板 raw
rg -n 'th:utext|<#noautoesc|\?no_esc|\|safe\b|mark_safe|Markup\(|<%- |!\{|\{!\!|template\.HTML\(' <target_path>
# 前端 raw
rg -n 'v-html|dangerouslySetInnerHTML|\.innerHTML\s*=|insertAdjacentHTML|document\.write' <target_path>
# Markdown raw
rg -n 'marked\s*\(|MarkdownIt\s*\(|markdown-it.*html\s*:\s*true' <target_path>
# 绕过 / 显式信任
rg -n 'bypassSecurityTrustHtml|trustAsHtml|template\.HTML\(|ADD_TAGS' <target_path>
每条命中标记输出上下文(HTML body / attr / JS / URL / CSS)。
Step 3:写读交叉
对每个写入字段,跨函数追踪它在哪些 SELECT / Find / Query 被读出 → 读出后流向哪些响应 / 模板上下文。
工具加速:调用 dataflow-analysis 做跨函数 source-to-sink 追踪;DB 字段维度的跨端点关联可结合 grep 字段名同名字段(comment.content 在 Mapper / Repository / Controller / template 中出现)。
写读交叉成立的字段 = 候选——进入 Step 4 判 sanitize。
Step 4:判 sanitize 实际位置
对每个候选字段追:
- 入库前 sanitize:写入 handler / Service / pre-save hook 是否调用 sanitizer(OWASP HTML Sanitizer / DOMPurify(server) / bleach / bluemonday)
- 回读后 sanitize:读出后在 Controller / 模板渲染前是否调用 sanitizer
- encoder 上下文匹配:模板默认 escape 仅适用 HTML body;attr 无引号 / JS 嵌入 / URL 协议位置需独立检查
- 白名单覆盖度:富文本 sanitizer 的 allowed tags / attributes / URL schemes 是否覆盖
javascript:/data:协议、on*事件、style属性
判定形态:
- 入库前未 sanitize + 回读 raw 输出 + 字段在写读交叉 → static-confirmed
- 入库前 sanitize 但用了不匹配上下文的 encoder(如只做 HTML body escape,回读位置在 attr 无引号) → static-confirmed
- 入库前 sanitize 但白名单缺 URL scheme / on* → static-confirmed(按缺口具体位置)
- 写读交叉成立 + sanitize 链完整 + encoder 匹配 → not_vulnerable
Step 5:富文本特殊审
白名单类 sanitizer 的配置审:
- DOMPurify 配置项
ALLOWED_TAGS/ALLOWED_ATTR/ALLOWED_URI_REGEXP/FORBID_*/ADD_TAGS实际值 - OWASP Java HTML Sanitizer 的 policy 组合是否覆盖所有可执行属性
- bluemonday
Policy自建时检查AllowAttrs(...).OnElements(...)是否遗漏 URL scheme 校验
mXSS 风险:DOMPurify 默认配置在某些 HTML parser 兼容性场景下可被绕过,标 static-confirmed 并附 PoC 范式或交黑盒验证。
Step 6:二阶通道扫描
- 列出所有 INSERT / UPDATE 端点的写入字段
- 对每个写入字段 grep 哪些 SELECT 引用,每个回读点独立看 sanitize 状态
- 重点:跨子系统(前台 / 管理后台 / 客服 / 邮件模板 / 导出 PDF)复用同字段时差异渲染常出问题
基线检查项
以下是已知的检查角度,作为基线起点而非必检硬清单。结合目标代码动态调整,按三态标注(
[x]/[-]/[+])处置。
- 所有用户可控字段写入点已枚举(按字段维度 grep 完)
- 所有 raw 输出 sink(模板 / 前端 / Markdown)已枚举
- 写读交叉集合已构造,每个 (write, read, field) 三元组单独看 sanitize 状态
- 富文本字段 sanitizer 配置已审(白名单标签 / 属性 / URL scheme 三层)
- Markdown 渲染器
html选项 / 外挂 DOMPurify 已确认 - 二阶通道(同字段多回读点)已扫描完
- 跨子系统差异渲染(前台 vs 后台 vs 邮件模板)已对照
- 闭源 sanitizer / 闭源富文本编辑器后端 / 动态模板选择路径已标
static-unknown
9. 闭环要求(必须遵守)
闭环判定 / 取证完整性 / 破坏性动作以 common/closure-verification.md 为准,下面只列本漏洞特有的判定上限与产物契约。
为什么这里是「必须」:本节属交付契约——产物结构关系到下游
result-with-file机器消费、按 (write_endpoint, read_endpoint, field) 三元组的覆盖完整性核验;产物聚合或省略会让整条链路失效,因此是刚性要求。
白盒判定上限
本能力作为白盒数据流审计原子能力,判定上限为 static-confirmed,不等于动态 confirmed。
static-confirmed(落 status=needs_review + confidence=high_confidence):
- 写入路径不 sanitize 或 sanitize 链缺口明确(白名单遗漏 URL scheme / on* /
style) - 回读模板使用 raw 输出 sink(
th:utext/v-html/dangerouslySetInnerHTML/innerHTML/{{|safe}}/<%- %>/ Markdown raw HTML) - 字段在 (write, read) 交叉集合
- encoder 与输出上下文不匹配(HTML body encoder 用在 attr 无引号 / JS / URL 位置)
static-unknown(落 status=needs_review + 标注 unknown):
- 动态模板渲染器选择(feature flag / 配置驱动)
- SSR 与 CSR 双路径并存且追踪不完
- 富文本 sanitizer 版本 / 配置闭源
- 自定义 sanitizer 实现无源码可见
- 闭源富文本编辑器后端规则不可见
not_vulnerable(落 status=not_vulnerable):
- 字段不在写读交叉
- 所有写入路径 sanitize + 所有回读路径 encoder 匹配输出上下文
- 富文本走严格白名单且覆盖标签 / 属性 / URL scheme 三层
- 字段全程纯文本上下文(
textContent/th:text/{{ var }}auto-escape 且无 raw 旁路)
升级路径(白盒不能独立给 confirmed)
- 走 graybox 流程:用白盒候选三元组指导黑盒端测试
- 黑盒端在浏览器实际渲染触发,按
pentest/xss-detection等 skill 的闭环要求收可观测效果证据 - 黑盒拿到强证据后,结论从
static-confirmed升为confirmed
禁止白盒独立判 confirmed——无浏览器实际渲染触发证据,仅静态可达不构成动态利用。
产物契约(必须遵守)
每确认一条候选立即 append 一行到 shared/coverage-ledger/findings/stored-xss-detection.jsonl,不等汇总阶段回头整理(why:"事后总结"是聚合 / 区间 / "等"省略的根源):
{
"id": "stored-xss-001",
"title": "评论 content 字段经 v-html 回显未 sanitize",
"severity": "high",
"cwe": "CWE-79",
"source": "comment.content (INSERT in POST /api/comment)",
"sink": "v-html in CommentList.vue:42",
"entry_point": "GET /article/:id",
"status": "needs_review",
"confidence": "high_confidence",
"file_location": "src/views/CommentList.vue:42",
"source_report": "stored-xss-detection",
"description": "..."
}
字段约束:
id带stored-xss-前缀全局唯一status ∈ confirmed | needs_review | not_vulnerable | false_positive | superseded(白盒默认needs_review)(write_endpoint, read_endpoint, field)三元组任一不同即各自独立成行——同字段在多个回读入口点 / 多个写入端点出现时禁止合并折叠source填写入端点 + 字段;sink填回读输出 API + file:line;entry_point填回显页面的 HTTP 入口点(method+URL)file_location填file:line,不留空、不写区间
禁止:
- 聚合计数("5 处 v-html + 3 处 dangerouslySetInnerHTML")—— 丢失具体位置,下游无法消费
- "等" / "..." / "(其余 N 处略)" 省略 finding
- 同字段多回读路径折叠为一行
反例义务(必须遵守)
why:白盒"未发现存储型 XSS / 已防护"结论是覆盖完整性产物声明,缺失反向验证会让下游误信"该子系统该维度安全"。
写"未发现存储型 XSS"或"已防护"前,产物必须包含:
- 所有用户可控字段的写入点完整清单(按字段维度)
- 所有 raw 输出 sink 完整清单(按文件 file:line)
- 完整的 (write_endpoint, read_endpoint, field) 三元组交叉清单
- 每个三元组的判定结果(sanitize 完整 / 缺口 / unknown)
static-unknown单元格的具体原因(动态模板 / 闭源 sanitizer / 自定义实现)
清单不完整 → 结论降级 partial-coverage。
10. 具象化反例库(共享章节)
FP(看似命中实际不构成)
反例 1:v-html 接收已 sanitize 过的 DOMPurify 输出
- 抽象规则:sink 命中
v-html但内容已在前置 computed / pinia getter 内经 DOMPurify 净化 - 具体场景:
<div v-html="purifiedContent">+computed: { purifiedContent() { return DOMPurify.sanitize(this.raw) } } - 关键识别特征:sink 接收的不是 raw source,而是 sanitize 结果;DOMPurify 配置无
ADD_TAGS扩白名单 - 排除方法:追 v-html 表达式上游、确认 DOMPurify 调用、核对配置无
ALLOW_DATA_ATTR/ADD_URI_SAFE_ATTR扩散;归not_vulnerable
反例 2:Thymeleaf th:utext 配合自定义 dialect 强制 escape
- 抽象规则:
th:utext看似 raw 但项目用自定义 dialect 在渲染管线插入 sanitize - 具体场景:项目实现
IDialect在 utext 前调用 OWASP Java HTML Sanitizer - 关键识别特征:
@Configuration注册了自定义 dialect,dialect 实现里有 sanitizer 调用 - 排除方法:读 dialect 实现确认是否对所有 utext 渲染前置 sanitize;归
not_vulnerable或needs_review视配置
反例 3:富文本编辑器(Quill / TipTap)自带服务端 sanitize
- 抽象规则:编辑器后端 SDK 提供"Delta → 受控 HTML"转换,已含白名单
- 具体场景:项目通过编辑器后端 API 拿到的是已转换的 HTML,不直接接收前端原始内容
- 关键识别特征:写入接口接收的是编辑器后端转换结果(Delta JSON 或 sanitized HTML)而非裸
<div>内容 - 排除方法:核对编辑器 SDK 文档及实际版本白名单覆盖;归
needs_review至not_vulnerable
反例 4:Markdown 渲染启用 markdown-it + 外挂 DOMPurify
- 抽象规则:
markdown-it({ html: false })默认拒绝原始 HTML,且渲染结果再过 DOMPurify - 具体场景:渲染管线
md.render(src) → DOMPurify.sanitize(html) - 关键识别特征:grep
markdown-it同位置含html: false或后续 sanitize 调用 - 排除方法:确认无
html: true重写、确认 DOMPurify 配置不放宽;归not_vulnerable
FN(看似不命中实际是真洞)
反例 5:Mutation XSS(DOMPurify 默认配置可绕过)
- 抽象规则:DOMPurify sanitize 后的 HTML 经
innerHTML重新 parse 时浏览器变形,触发 mXSS - 具体场景:
element.innerHTML = DOMPurify.sanitize(input)+ 特定 HTML 构造触发 parser 变异 - 关键识别特征:sanitize 链看似完整,但 sink 仍是
innerHTML/dangerouslySetInnerHTML - 确认方法:用 DOMPurify CHANGELOG / 已知 mXSS payload 测试;标
static-confirmed推黑盒在浏览器实际渲染验证
反例 6:URL 上下文里 javascript: 协议被放过
- 抽象规则:HTML body sanitize 通过,但
<a href="{{var}}">位置 sanitizer 不校验 URL scheme - 具体场景:bleach 默认
ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']但项目自定义加了'javascript' - 关键识别特征:sink 是
href/src/formaction/data等可执行 URL 属性 - 确认方法:审 sanitizer 的 URL scheme 白名单配置;标
static-confirmed
反例 7:JSON 嵌入 HTML 时未做 < 编码
- 抽象规则:服务端把数据序列化为 JSON 嵌入
<script>var data = {{json}}</script>,缺</</script>转义 - 具体场景:Go
template.JS(...)用错、Jinja2|tojson旧版本不转义</script> - 关键识别特征:模板里
<script>内嵌 JSON 字面量 - 确认方法:测试
</script>闭合 + 注入;标static-confirmed
反例 8:React dangerouslySetInnerHTML 渲染 user-provided SVG
- 抽象规则:SVG 内
<script>/onload可执行,sanitizer 若按 HTML 白名单未覆盖 SVG 子集即漏 - 具体场景:用户上传 SVG → 详情页
<div dangerouslySetInnerHTML={{__html: svgContent}}> - 关键识别特征:sink 接收 SVG 内容;DOMPurify 配置缺
USE_PROFILES: { svg: true, svgFilters: true }或反之过宽 - 确认方法:测试 SVG
<script>payload;标static-confirmed
易混淆案例
反例 9:闭源富文本编辑器后端 sanitize 规则不可见
- 抽象规则:项目依赖闭源富文本编辑器 SaaS 后端,白名单规则不在源码中
- 具体场景:调用
editor-saas.transform(content)后写入 DB,回读时直接 v-html - 关键识别特征:依赖图谱里的
unknown标注 + 调用形态是远程 API - 排除方法:标
static-unknown,不能默认 not_vulnerable
11. 静态分析边界
白盒底线:不假装看到看不到的代码。CSP 影响不评估(属 csp-audit)。
下面这些情形数据流分析无法继续追踪,必须标 static-unknown,不允许默认为 not_vulnerable:
- 动态模板路径 / feature flag:SSR 与 CSR 双路径切换、A/B test 切换富文本编辑器版本、配置驱动的模板引擎选择(不同租户不同渲染器)→ 每条分支独立审计,至少标
static-unknown列出分支条件。 - 自定义 sanitizer 实现:项目自建
HTMLSanitizer类、字符串替换式 sanitizer(replaceAll("<", "<")形态)→ 规则集不完整即static-confirmed;不可见或复杂控制流static-unknown。 - 闭源富文本编辑器后端:三方编辑器 SaaS 调用(Quill Cloud / TinyMCE Cloud)/ 闭源 jar / npm package 内部 sanitize → 依赖图谱标
unknown,推 dependency-decompile;不能默认 not_vulnerable。 - 跨服务 RPC 拿到的字段:微服务架构下字段来自上游 RPC / Kafka 消费 → 本服务追到 RPC 入站即停,标
static-unknown由上游独立审。 - SSR 与 CSR 双路径:Vue / React SSR + hydration 客户端可能用
innerHTML重渲 → 两端独立看 sink。 - 运行时配置切换 sanitizer 行为:DOMPurify
ADD_TAGS被环境变量扩展 → 读配置文件 / 环境变量定义;不可见标static-unknown。 - 反射 / 动态属性访问:JS
el[attrName] = value中attrName动态决定 → 标static-unknown记录反射点。
CSP 影响:CSP 是浏览器侧的 mitigation,本能力不评估——CSP 强弱属 csp-audit 职责。白盒 static-confirmed 不因 CSP 强而降级,反之亦然。
底线:本能力写"该子系统无存储型 XSS"前,所有 static-unknown 单元格必须显式列出原因。否则结论降级 partial-coverage。
12. 修复建议(共享章节)
源头治理(首选)
- 入库前 sanitize:用业界成熟白名单库
- Java:OWASP Java HTML Sanitizer (
Sanitizers.FORMATTING.and(Sanitizers.LINKS).and(Sanitizers.BLOCKS)等组合) - JavaScript(服务端):
isomorphic-dompurify/sanitize-html - Python:
bleach.clean(input, tags=ALLOWED, attributes=..., protocols=ALLOWED_PROTOCOLS) - Go:
bluemonday.UGCPolicy()或自建严格 policy
- Java:OWASP Java HTML Sanitizer (
- 模板输出 auto-escape 默认开 + 显式 opt-out 走 sanitize:禁止全局关闭 auto-escape;显式 raw 输出位置必须经 sanitize 后再传入
- 富文本走严格白名单:标签 + 属性 + URL 协议三层都要覆盖;URL 协议白名单仅
http/https/mailto,禁javascript:/data: - Markdown 渲染启用 sanitize:
markedv4+:渲染结果外挂 DOMPurifymarkdown-it:保持html: false,自定义 token 替代原始 HTML 需求
- JSON 嵌入 HTML 用 unicode escape:
<→<、/→\/,避免</script>闭合
边界过滤(次选,深度防御)
- 输出端二次 encoder 匹配上下文:模板默认 auto-escape 仅适用 HTML body;attr 无引号 / JS 嵌入 / URL 协议位置独立处理
- CSP 兜底:在 csp-audit 指导下配置
script-src/style-src/object-src收敛执行面;CSP 不替代 sanitize - HttpOnly Cookie:限制 JS 访问会话标识,降低 XSS 利用价值(不阻断 XSS 本身)
兜底拒绝
- 富文本字段大小上限 + 内容类型校验
- 高危标签 / 属性 / 协议在 WAF 层补一层拒绝(仅作辅助)
- 错误响应不暴露内部模板路径 / sanitizer 配置