Java常用类库-27-转义上下文与受控文本插值
同一段商品文本会进入不同语法
商品名称既可能显示在页面正文,也可能放进 HTML 属性、JSON 接口和 XML 导出。另一个需求是将模板中的商品编号替换为实际值,或者找出拼写相近的编号。这些任务都接收字符串,却不共享一种“安全处理”方法:输出编码取决于目的语法,插值取决于允许访问的数据,相似度计算取决于输入规模。
本篇固定 Commons Text 1.15.0,提交 04e937470d3679cc163df85d82d5b6d2e3e71128。实验在 JDK 8 与 JDK 21 上使用同一组输入,只访问内存中的合成商品数据,不读取真实环境变量或文件。完整代码见 Chapter27Test.java,执行方式见 RUN.md。
业务契约分成三个可验证条件:文本按照最终输出位置编码;模板只能查询明确提供的键,未知键报错,值中的占位符不递归展开;距离计算先限制输入长度,再判断是否超过距离阈值。缺少其中任一条件,都可能出现输出语法被改变、模板获得额外能力或单次计算成本失控的问题。
HTML 正文的转义结果不能直接用于所有属性
实验把 <b>&"' 交给 escapeHtml4,结果为 <b>&"'。尖括号、与号和双引号变化了,单引号仍然存在。这个结果符合 HTML 4 实体集合的实现,却不能支持“所有引号都被处理”的推断。
如果输出位置是单引号包围的属性,保留下来的单引号可以改变属性边界。即便改用双引号,事件属性、URL 属性和普通文本属性的语义也不同。把某段值写入 onclick 或 href 时,仅验证 HTML 语法字符并不能决定脚本或 URL 是否允许执行。属性应由模板或 DOM API 按既定类型设置,URL 还需要独立的协议政策。
StringEscapeUtils 固定源码为 HTML、JSON、ECMAScript 和 XML 建立不同的 translator。它们负责字符转换,没有一个通用方法能够推断调用方准备把结果放进哪种语法位置。调用方必须先确定上下文,再选择相应工具。
| 目的位置 | 本组实验能证明的行为 | 仍需独立处理的条件 |
|---|---|---|
| HTML 正文 | escapeHtml4 转换尖括号与与号 | 不应将结果再当成标签结构 |
| 单引号 HTML 属性 | 单引号没有被 escapeHtml4 转换 | 使用上下文适配的属性输出方式 |
| JSON 字符串内容 | 双引号和换行转换为 JSON 转义 | 完整对象应由 JSON 序列化器构造 |
| JavaScript 字符串 | escapeEcmaScript 处理单引号 | 还要考虑所在 HTML 容器 |
| XML 1.0 文本 | 实验中的 NUL 被删除 | 数据丢失政策与完整文档合法性 |
JSON 字符串与内联脚本之间还有一层解析
escapeJson 对双引号加换行的输入得到反斜杠引号和反斜杠 n。这个断言检查字符串内部编码,没有构造完整 JSON 文档。把转义后的内容直接拼到未加引号的 JSON 位置,仍会产生错误;因此,实际接口优先交给 JSON 序列化器处理对象结构。
实验也检查 escapeJson("</script>") 的结果仍包含小于号。这个断言的用途是阻止把 JSON 编码误认为 HTML 容器编码。将 JSON 嵌入 script 元素时,浏览器首先处理 HTML 层的结束标签边界,之后才轮到 JavaScript 或 JSON 内容。数据在某层合法,不表示跨过外层解析器仍然安全。
同理,ECMAScript 的字符串转义并不等于整段 JavaScript 程序验证。它不知道结果将出现在字符串、模板字面量、注释还是可执行表达式中。需要传递结构化数据时,避免手工拼接可执行脚本,通常比不断补充字符替换规则更容易验证。
XML 的限制还涉及可表示字符集合。实验输入 a、NUL、b,escapeXml10 输出 ab;非法控制字符被移除,并没有保留为某种可逆形式。对于商品标识或签名原文,这种变化可能不可接受,应用应在编码前拒绝原始数据或选择明确的二进制表示。对于展示文本是否允许删去控制字符,也应成为显式业务政策。
第一项可迁移模式是按解析层分别定义输入和输出。数据跨越 HTML、脚本、JSON 三层时,每层都有自己的结构字符和合法值范围。方法名中出现 escape 只能证明进行了某种转换,不能代替最终位置的契约。
插值器的能力由 lookup 决定
商品标签模板只需要 sku 和 label。测试直接将这两个键放入 Map,再用 new StringSubstitutor(values) 创建实例。这个构造方式将查询限制在提供的映射中,没有启用通用 interpolator,也没有登记文件、系统属性或环境变量查询器。
模板 item=${sku} 得到 item=SKU-1。${env:SYNTHETIC_ONLY} 被当作映射中不存在的键,随后抛出异常;实验没有执行一次真实的环境查询来证明拒绝。这里的证据是所使用的 lookup 类型、提供的键集合以及未知键的失败结果。
StringSubstitutor 固定源码将变量解析与替换流程分开。系统是否具备某种查找能力,取决于配置的 resolver,而不只取决于模板里有没有看起来危险的前缀。先开放广泛查询能力,再尝试用字符串黑名单屏蔽,较难完整解释可接受范围。
本例设置 enableUndefinedVariableException 为 true。未知键将中止生成,而不是把残留占位符作为成功结果交给下游。这适合需要完整配置的商品导出;如果模板编辑器允许暂时保留缺失变量,应明确区分“预览未完成”和“导出已完成”,不要共用一个含糊的成功标志。
递归替换与变量名替换是不同开关
还需关闭默认值分隔符。上游测试表明,存在 ${unknown:-fallback} 这类默认值时,未知变量异常开关本身不会拒绝默认替代。本例额外将 valueDelimiterMatcher 设为 null,并断言这个输入同样失败,避免“未知键必须报错”的政策被默认值语法绕过。
映射中的 label 值为 ${sku}。默认递归替换时,${label} 最终得到 SKU-1;将 disableSubstitutionInValues 设为 true 后,结果保持 ${sku}。两者都查找了 label,但后者不会继续解释取得的值。
这一区别对不可信商品描述尤其重要。描述中出现 ${...},可能只是用户希望显示的文本,不应自动取得模板语言的语义。把模板结构与商品值分开,能够避免数据在第二轮处理时变成指令。测试还将 enableSubstitutionInVariables 设为 false,使变量名自身不通过嵌套替换动态生成。
循环也是单独一种失败路径。映射 cycle 指向 ${cycle},默认递归实例抛出 IllegalStateException。检测直接循环不等于限制所有输出膨胀:无环模板也可以重复引用长值,产生很大的字符串。因此,即使禁止递归,入口模板长度、单值长度、变量数量与最终输出长度仍应按业务预算控制。
本组三个测试没有实现完整的流式有界模板引擎,也没有证明超大替换结果在分配前被拦截。输出长度后验检查只能拒绝交付,不能撤销已经发生的内存分配。需要强内存预算时,必须选用能边生成边计数的方案,或通过输入总量与引用次数事先给出可证明的上界。
第二项可迁移模式是先收窄能力,再规定失败语义。Map 白名单限定可以访问什么,未知键异常限定不完整结果如何处理,禁用值递归限定数据是否继续被解释。三个条件互相补充,任何一个都不能自动推出另外两个成立。
相似度阈值没有限制输入长度
商品编号纠错可以使用 LevenshteinDistance。阈值为 2 时,SKU-1 与 SKU-2 的距离为 1;abcd 与 wxyz 的真实距离超过阈值,方法返回 -1。这里的 -1 表示超过阈值,不是负距离,也不能直接进入“距离越小排名越靠前”的排序逻辑。
LevenshteinDistance 固定源码包含有阈值和无阈值的计算路径。有阈值实现缩小动态规划中需要计算的区域,但仍需读取输入、处理长度,并执行与输入有关的操作。阈值限定可接受编辑距离,没有把任意长度输入变成恒定成本。
测试的 boundedDistance 先比较两个字符串的 length,超过 maxChars 就抛异常,再调用阈值为 2 的距离算法。maxlength 为 3 时,abcd 被入口拒绝;abc 与 abd 得到 1。这个简单次序把资源政策放在算法之前,而不是希望算法返回 -1 时顺便承担输入限制。
Java String.length 计数的是 UTF-16 代码单元。对补充平面字符、组合字符和人眼看到的字形,代码单元长度未必等于字符数量。本例处理 ASCII 商品编号,因此长度预算与业务字符数一致;如果改为自然语言名称,需要另外决定正规化、大小写、代码点或字素边界,不能把编号实验推广为完整的多语言文本匹配规则。
JDK 替代与验收范围
距离结果进入排序之前还需要定义候选集合。若每个请求把一百万个编号逐个计算一遍,即使每对字符串都很短,整体工作量仍可能很大。长度上限约束单次比较,候选数上限约束一次请求;两者与返回最多几个结果没有直接等价关系。先查前缀索引或其他业务索引缩小候选,再运行编辑距离,通常更容易解释成本。
阈值之外的 -1 应先过滤,而不是直接作为排序键。否则按照整数升序排列时,最不相近的候选反而可能排在距离零之前。若两个候选距离相同,还需要稳定的第二排序条件,例如完整商品编号,避免结果依赖容器遍历顺序。测试当前只验证距离值,候选筛选与排序属于应用层扩展。
转义的重复执行也不是通用容错办法。原始与号经过 HTML 编码后变成实体表示,再次编码可能把实体开头的与号继续转换,最终页面显示的是实体文本。应用应给原始数据和已生成输出明确的流转位置,避免某个服务提前转义、模板层又转义一次。保存原始值、在最终输出边界按上下文编码,比在数据库中混存多种展示形式更容易维护。
JDK 没有与本篇三组功能完全对应的统一文本工具类。简单、固定的模板可以显式拼接已校验字段,从而不引入模板语言;结构化 JSON、HTML、XML 应优先使用对应格式的构造与输出设施。少量固定 ASCII 编号的比较也可以采用更窄的业务规则,不必为了“可能相似”引入通用距离排名。
双 JDK 实验验证三组场景:目的上下文的具体字符输出,白名单查询与递归开关,距离阈值和长度拒绝。没有浏览器执行、模板性能基准或全部 Unicode 等价性测试。因此,字符级断言支持 API 契约分析,不构成完整页面的安全测试报告。
手算题:label 为 ${sku}、sku 为 SKU-1,关闭值递归后替换 ${label},结果是什么?答案仍是 ${sku}。改动练习是在 Map 中增加一个很长但不递归的值,让模板重复引用它,先计算输出上界,再决定应在输入处限制什么;仅在 replace 返回后比较长度不应被记为分配前保护。
| 判断关键词 | 可迁移模式 | 具体选择 |
|---|---|---|
| HTML、JSON、XML | 按解析层定义编码 | 不把一种 escape 用遍所有位置 |
| lookup | 能力先收窄 | 显式 Map 键,未知变量失败 |
| 递归 | 数据是否继续解释需明确 | 商品值关闭递归展开 |
| threshold | 算法阈值与资源预算分开 | 先检查长度,再计算距离 |
可独立复跑的 Maven 项目见 下载 25–30 实验包,包含固定依赖配置、完整源码与 Maven Wrapper。
系列起点:可复现基线。


