☰
OWASP JSON Sanitizer实战:让JSON清洗不再怕XSS与脏数据
2026/10/21 6:57:05 网站建设 项目流程

在对接第三方系统的时候,你有没有遇到过这种情况:对方传过来的 JSON 里,本该放字符串的位置,硬生生塞了一段带<script>的 HTML,或者字符串里混着奇奇怪怪的编码字符。如果是自己团队写的代码,大不了联调时骂两句,让对面改。但换成外部不可信数据源,情况就麻烦很多——你没法要求对方改格式,可又不能让这种脏数据直接灌进自己的业务系统。我早先在某个数据采集项目里就撞见过一次,当时试过自己写正则清洗,结果字符边界、嵌套引号、转义符各种边缘情况,修修补补大半年,最后还是换成了 OWASP JSON Sanitizer 才真正把这摊事理顺。

这篇就专门说这个组件。我主要围绕它是什么、适合解决什么问题、接入时哪些坑必须避开这几个方向展开,重点整理我在项目里实际踩过的那些“文档里没细写”的坎。如果你现在正打算引入它,或者已经被一堆畸形 JSON 折磨得焦头烂额,这篇应该能帮你省不少时间。

1. OWASP JSON Sanitizer 到底治什么病

1.1 它解决的核心问题

先聊清楚一个概念:JSON Sanitizer 不是一个 JSON 解析器,它更像是“JSON 净化器”。它接收一段来源不可信的字符串,然后产出一个“严格合法且安全”的 JSON 字符串。关键点在于,它不要求输入本身是合法的 JSON——恰恰相反,它专门处理那些不合法的、混合了其他内容的“脏数据”。

这听起来有点抽象,我举个例子。假设外部系统往你的接口传了这么一段数据:

{"name": "<img src=x onerror=alert(123)>", "age": 25}

如果直接把这个值取出来塞到前端页面里,浏览器一渲染,onerror就触发了。这其实就是典型的存储型 XSS(跨站脚本攻击)场景。很多团队的第一反应是“我后端加个过滤不就行了”,但真试过的人都知道,只靠关键词黑名单过滤是堵不住所有攻击向量的,绕过姿势太多了。

JSON Sanitizer 的思路不一样:它不关心你是恶意还是误操作,它只保证一件事——让输出的内容在“任何接收方解析后”,得到的一定是一个“只含合法 JSON 语义”的值。HTML 标签、脚本代码这类内容会被转义成纯文本形式,而不是被当作可执行结构传下去。

再说直白一点:这个组件提供的核心能力是JSON 数据安全清洗,它把不可信输入变成“安全的 JSON 输出”,让下游系统(比如前端渲染、配置中心、消息队列)接收时不会再发生注入意外。

1.2 什么时候适合用、什么时候别硬上

有段时间我一度把它当万能工具用,后来才摸清楚它的适用边界。先说不适合的场景:

  • 如果你的目标是“从 JSON 里提取某个字段”,那不要用这个,直接用标准 JSON 解析库(比如 Jackson、Gson)就好。
  • 如果你需要严格校验“这段数据是不是合法 JSON”,也不需要它,它本身的宽容性反而会干扰你的校验逻辑。
  • 如果数据源是自家内部系统且格式绝对可控,那也轮不到它上场。

适合的场景主要这几类:

  • 接收第三方开放接口、爬虫抓取的数据、用户上传的配置文件。这些来源的数据你没法约束格式,内容里混什么都可能。
  • 需要把外部数据原样存储或转发(比如存到消息队列供前端消费),但又必须防止脚本注入。
  • 你在做 API 网关统一出口的清洗层,所有出网数据先过一遍清洗再给外部消费方。

我在那个采集项目里,主要就是用它处理从网页端采集回来的信息流。那些数据里混杂着标签片段、HTML 实体、控制字符,原样存下来会污染整个数据仓库,直接丢弃又会丢信息,清洗后保存才是对的方案。

2. 依赖引入与基础接入方式

2.1 Maven / Gradle 依赖配置

这个组件是 OWASP 旗下的开源项目,在 Java 生态里接入非常方便。Maven 配置长这样:

<dependency> <groupId>com.mikesamuel</groupId> <artifactId>json-sanitizer</artifactId> <version>1.2.3</version> </dependency>

Gradle 则用:

implementation 'com.mikesamuel:json-sanitizer:1.2.3'

这里提醒一个版本细节。我在项目里最早用的是 1.1.x,后来升级到 1.2.3,差异主要体现在对一些边界输入的容错处理上。具体来说,旧版本处理形如{"a":}这类不完整键值对时,输出的格式不够整洁,升级后输出的一致性好了不少。建议直接上最新稳定版本,不要停在老版本上“差不多就得了”。

2.2 三种核心 API 调用方式

这个组件的入口类叫JsonSanitizer,核心就一个静态方法sanitize,根据返回类型不同分三种用法:

// 第一种:返回清洗后的 JSON 字符串 String safeJson = JsonSanitizer.sanitize(input); // 第二种:把结果写入 Appendable StringBuilder builder = new StringBuilder(); JsonSanitizer.sanitize(input, builder); // 第三种:配合 JsonWriter 做更精细的控制 String safeJson = JsonSanitizer.sanitize(input, 10);

参数里那个数字就是“输出缩进格数”,传10意思是用 10 个空格做 JSON 格式化展示。如果你只是要一份紧凑的 JSON(本意是省流量、方便下游机器直读),直接用一个参数的重载即可,它会输出无多余空格的结果。

实际使用中,我一般把第一和第三种搭配着用:内部存储用紧凑格式,调试时用带缩进的版本看结构,排查问题时特别舒服。

2.3 给方法套个“防护罩”

这个组件虽然叫 Sanitizer,但它本身不负责捕获异常。如果你的业务代码是那种“输入格式完全未知”的场景,建议在调用外层套一层异常兜底,防止极端情况下它自己抛出异常把业务链路打断。我自己常写这么一层:

public String safeSanitize(String input) { if (input == null || input.isEmpty()) { return input; } try { return JsonSanitizer.sanitize(input); } catch (Exception e) { // 记录原始输入和异常详情到日志,方便后续溯源 log.warn("json sanitize failed, input={}", input, e); return "{}"; } }

这层兜底非常重要。我之前有次接入时没做异常保护,结果上游送了一段极端的畸形数据,直接导致接口 500,排查半天才定位到是 Sanitizer 对某类输入会抛RuntimeException。从那之后我学乖了,凡是第三方开源组件,务必要给它留一条“后路”。

3. 高频问题逐一拆解

下面进入正题,把我在实际项目中遇到的最常见、最容易让初学者栽跟头的问题,一个接一个说清楚。每个问题我都会按“现象 → 原因 → 解决方案 → 验证方法”的顺序来讲,方便你对照排查。

3.1 问题一:字符串里的 HTML 片段不生效,但担心被截断

现象是,用 Sanitizer 处理完一段 JSON 后,原来字符串值里的<div>、<script>标签变成了类似\u003cdiv\u003e的形式。有的同事看到这种结果会心慌,觉得“数据是不是被弄坏了。”

这里说明一下,它没弄坏,恰恰是它在干活。\u003c就是<的 Unicode 转义写法。JSON 规范里允许这种表示方法,任何标准 JSON 解析器拿到\u003c之后会自动还原成<。所以在这个输出里,换一个角度看,内容是等价的,但“形式上”的脚本标签被拆得七零八落,使得浏览器在解析 HTML 时不会把它认定为可执行标签。

简单理解,它做的事情更像“把<script>改成\u003cscript\u003e再交给浏览器”,浏览器一看这个字段不是真正的标签结构,就不会怵。所以当你看到一连串\u003c、\u003e时,先别急着动代码,这就是正常的清洗结果。

验证方法很简单:把清洗后的字符串交给标准解析器解析,再输出解析后的值,应该和原值一致。如果原值是 “<b>加粗</b>”,清洗后解析出来还是 “<b>加粗</b>”,只是字符串本身用于 HTML 拼接时不再产生标签语义。

3.2 问题二:特殊字符变成 \uXXXX 后,下游看不懂

这是新手最常见的一个疑惑。比如中文字符“中”在清洗后可能不会转,但某些特殊控制字符或者 HTML 敏感字符就会变成\uXXXX形式。很多人觉得“好好的订单号怎么变成了乱码”。

其实 \uXXXX 是标准的 Unicode 转义,不是乱码。JSON、JavaScript、Java 都支持这种写法,任何符合规范的解析器都会自动把它还原。如果下游因为看不懂就报错,问题一般出在下游用了非标准解析工具,它没有按 JSON 规范处理转义字符。

处理思路有两条:

  • 如果是自己团队控制的下游,就去修正解析逻辑,用标准解析器。
  • 如果是不可控的第三方系统,那就考虑在清洗后单独做一层“反转义”输出,但这时候你要自己保证业务安全。我自己做得比较多的做法是,默认保留\uXXXX格式,因为多一层转义少见问题。

3.3 问题三:输入根本不是 JSON,程序却不报错

有些同学拿它做格式校验,结果发现传一段纯文本“hello world”进去,Sanitizer 返回的是"hello world"——一个合法的 JSON 字符串。他们就疑惑:“它怎么不抛异常?这不是不合法吗?”

这里要再强调一下设计定位。Sanitizer 的目标是“把输入变成合法 JSON”,而不是“判断输入是否合法 JSON”。对于无法按 JSON 对象解析的内容,它会当成 JSON 字符串处理,用引号包起来。这是特性,不是缺陷。

如果你确实需要区分“原本就是 JSON 对象”还是“被包成字符串的普通文本”,那应该在清洗之前先自己用解析器做一层判断。实际操作时我一般这么配合:

public String sanitizeWithCheck(String input) { // 第一步:先判断合法 JSON 类型 boolean isJson = true; try { ObjectMapper mapper = new ObjectMapper(); mapper.readTree(input); } catch (Exception e) { isJson = false; } // 第二步:无论如何都过清洗 String safe = JsonSanitizer.sanitize(input); if (!isJson) { // 此时 safe 一定是 JSON 字符串类型,逻辑上注意区分 log.info("non-json input converted to json string"); } return safe; }

这样做,既能享受清洗的防护能力,又不丢失格式判断信息。

3.4 问题四:清洗后的大 JSON 性能不达标

如果你需要处理几百 KB 甚至几 MB 的对象,清洗耗时可能会明显上涨。这个组件用纯 Java 实现,内部要做字符级扫描和状态机维护,复杂度接近 O(n),也不算离谱,但数据量大了之后,GC 压力和内存复制消耗就上来了。

性能优化我试过几招,效果最明显的是避免频繁的字符串创建。sanitize(String)这个重载内部会创建多个中间对象,如果你的流量高,建议改用sanitize(input, builder)这种直接把结果写入复用StringBuilder的方式:

StringBuilder reusedBuilder = new StringBuilder(1024 * 1024); for (String input : rawRecords) { reusedBuilder.setLength(0); JsonSanitizer.sanitize(input, reusedBuilder); String safeJson = reusedBuilder.toString(); // 处理 safeJson }

用setLength(0)清空并复用同一个 StringBuilder,避免每次分配新缓冲,对比下来性能提升非常明显。至于文档里提到的“超大输入拆分处理”,实践下来不如直接调大 JVM 堆内存来得省事,除非你的输入真的超过几十 MB 量级,否则不建议做复杂的分块方案。

3.5 问题五:清洗后所有内容挤在一行,可读性差

这个问题几乎每个刚接入的人都会遇到。默认输出是紧凑格式,整个大对象变成一行长串,想肉眼查看某条数据时非常痛苦。

这时候就用第三个重载:

String prettySafeJson = JsonSanitizer.sanitize(input, 2);

第二个参数传 2 或 4,输出就会带缩进。这样在排查问题、人工核对数据时舒服得多。不过需要注意,格式化后的 JSON 体积更大,网络传输流量更高,所以不要把这种“美化版”作为线上接口的最终输出格式——生产和调试要分开来用。

4. 工程化接入时容易踩的深层坑

4.1 版本兼容:JDK 版本与模块系统

组件是 Java 6 时代的产物,类库很老,但反过来在 Java 8 到 Java 17 上实测下来都能正常工作。需要留意的是,如果你的项目引入了模块化(JPMS),可能需要配置自动化模块名。实际操作中,很多团队直接在module-info.java里把它加入类路径而非模块路径,省事但不规范。我这里给出的建议是,如果项目已经全面模块化,就先建一个简单的测试用例验证模块间的依赖解析,否则直接放类路径运行也问题不大。

4.2 误伤正常业务数据的情况

清洗机制比较激进,某些场景下它会把你以为的“正常内容”也转掉。比如你有个业务字段叫 notify_url,值本来是https://example.com/api/callback?type=1&flag=2&key=<abc>,清洗后&依然保留(它合法),但=和&之间的结构可能发生变化吗?其实不会,URL 里的等号和与符号都是合法字符,清洗不会动它们。真正容易被转的是<、>、&这几个在 HTML 里意义非凡的字符。

如果你对接的下游系统比较老旧,不习惯处理转义后的值时,你需要评估一下是“整体替换为转义方式”还是“针对固定字段做白名单豁免”。我个人不建议随便豁免白名单,因为一旦开了这个口子,后面各种绕过会如同洪水泛滥。实在要豁免,必须有非常严格的字段级控制,例如仅对受信任来源的数据生效。

4.3 与日志审计系统的配合问题

接入层面常忽略的一个点:清洗后的数据最好打印到日志里,而不是清洗前的原始数据。原因很简单——清洗前的原始数据可能已经包含攻击载荷,一旦在日志系统里落盘,如果日志检索系统直接把\u003cscript\u003e还原成<script>再输出到前端,那等于绕过了业务层清洗,攻击载荷又在日志系统里复活了。

我处理的这个项目,日志中心使用的是分布式检索框架,支持全文索引并高亮显示。如果原始含脚本的数据被直接索引,运维人员检索日志时浏览器就可能解析高亮内容。这类问题隐蔽性极强,很多人接入完 Sanitizer 后就把注意力放在业务接口上,完全忘了日志链路。

实操上的解决办法:

  • 打印前统一用 Sanitizer 清洗。
  • 日志框架里加一个多层转义的自定义转换器,把\u003c再次转为可安全展示的形式。

这一步看似多余,但真到出事故那天能救你一条命。

4.4 与消息队列结合时的先后顺序

有的系统架构是:接口接收数据 → 塞进消息队列 → 消费端解析处理后入库。很多人觉得在消费端清洗就行,接口那层不洗。“反正最终入库前会过一遍”——这种想法很危险。

原因在于,消息队列里流转的数据可能被多个系统消费。如果一个内部报表系统直接从队列里取数据展示,又不经过清洗,那就成了攻击面。所以我的建议是,在“数据进入消息队列之前”就必须清洗干净。队列里面只应该存在安全数据,而不是“等着别人再保护我的脏数据”。这和“默认信任边界”的安全理念是一脉相承的。

5. 高频排查速查表

下面这张表是我在项目里多年攒下来的一些典型问题的排查方向,建议直接截图保存或者贴在 Wiki 里,遇到问题对着查就行。

现象可能原因处理方式
清洗后标签变成 \u003c 形式正常转义生效无需处理,标准解析器可还原
输入了明显非法 JSON,却不报错Sanitizer 不校验格式自行用解析器先做合法性判断
大 JSON 处理超时字符串频繁创建,GC 压力大复用 StringBuilder,或加大堆内存
输出 JSON 全部挤在一行默认紧凑格式用传缩进参数的重载调试
含敏感字符的数据在日志中出现日志链路未清洗日志输出前统一过 Sanitizer
清洗后空值变成null字符串输入是null字面量业务层判断需特别注意

6. 我的一些实践心得与收尾

写到这里,我最后再分享两个实操里拿命换来的经验。

第一,永远不要把“过滤”做成“一次性动作”。很多系统清洗后就把数据存库,觉得完了,结果下游某个新系统接的是原始数据源,风险又被引爆了。我现在的习惯是,数据边界每一层都默认“外界不可信”,只有真正落库之后才被视为受控数据。尤其在对接外部数据源、开放接口、爬虫采集这类场景,这个原则会让你少挨不少骂。

第二,有条件的话,把 Sanitizer 包一层你自己的包装服务,统一入口、统一告警、统一日志记录。不要到处直接散着调用裸 API。这样以后想加白名单、想调整清洗策略、想统计清洗率,都行得通。我自己的项目里已经把它做成了一个微服务,内部几个业务系统都是走这个统一网关清洗数据,效果比让每个团队自己接一遍好得多。

这个组件的定位很轻,它不会解决你所有安全问题,但它是安全链路上一个非常实用的“滤网”。如果你正处在对接外部不可信数据的阶段,建议花半小时接入试一下,跑几个畸形输入的用例,感受一下它的脾性,后面踩坑的概率会小很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询