发布于
XML 携带两个彼此独立的正确性问题,而人们习惯把它们合成一个。文档是否良构 —— 它到底能不能被解析?文档是否有效 —— 它符不符合声明的 schema?工具对任何文档都能回答第一个问题,只有在你提供 schema 时才能回答第二个。把这个区分弄清楚,再加上了解命名空间和空白对查询的影响,实践中 XML 出的问题基本就覆盖了。
良构和有效是两种不同的断言
良构是完全由 XML 规范定义的语法属性。一份良构的文档有且只有一个根元素,每个开始标签在正确的嵌套层级上都有配对的结束标签,每个属性值都加了引号,属性名在同一元素内唯一,且字符数据中不出现裸的 `<` 或 `&`。解析器要么接受文档,要么报告致命错误;没有「部分成功」,而且规范要求解析器停下来而不是尝试恢复。这种严格是有意为之的,也是 XML 相对 HTML 最主要的优势。
有效性则是由外部 schema 定义的语义属性。它问的是:`<order>` 里能不能放 `<lineItem>`,`quantity` 是否必须是正整数,`shippedOn` 是不是可选。一份文档可以完美良构,同时对它的用途完全错误。反过来,无法通过良构检查的文档根本谈不上校验有效性,因为没有东西可供比对 —— 先修语法。
| 问题 | 需要什么 | 能抓到什么 |
|---|---|---|
| 能不能解析? | 只需要文档本身 | 未闭合标签、裸 `&`、两个根、编码错误 |
| 符不符合契约? | XSD、DTD 或 RELAX NG schema | 缺失元素、类型错误、基数不对 |
| 满不满足业务规则? | Schematron,或者代码 | 「若 type 为 `credit`,则 `iban` 必填」 |
| 这个路径上的值对不对? | 一条 XPath 表达式 | 快速检查某个具体字段 |
| 解析它安不安全? | 解析器配置,与文档无关 | XXE、实体膨胀、外部 DTD 拉取 |
转义:五个实体,两种上下文
XML 只预定义了五个实体:`<`、`>`、`&`、`"` 和 `'`。其余的一切 —— ` `、`©`,以及大家习以为常的几百个 HTML 实体 —— 在 XML 里都是未定义的,除非某个 DTD 声明过它们。这是「浏览器能渲染、解析器却拒绝」最常见的原因:文档是按 HTML 的习惯写出来的。改用 ` ` 这样的数字字符引用,它任何时候都有效。
哪些转义是必需的取决于上下文。在字符数据里,必须转义 `<` 和 `&`。除了 `]]>` 这个特定序列之外,`>` 不需要转义,不过顺手转义也无害、也很常见。在属性值里,必须转义 `<`、`&`,以及界定该值的那种引号;另一种引号可以字面出现,这就是为什么 `title='He said "no"'` 是合法的。
| 字符 | 引用写法 | 在元素文本中 | 在属性值中 |
|---|---|---|---|
| `<` | `<` | 必需 | 必需 |
| `&` | `&` | 必需 | 必需 |
| `>` | `>` | 仅在 `]]>` 内部 | 不必需 |
| `"` | `"` | 不必需 | 值用双引号时必需 |
| `'` | `'` | 不必需 | 值用单引号时必需 |
| ` ` 及其他 HTML 实体 | XML 中未定义 | 改用 ` ` | 改用 ` ` |
| 控制字节 0x00–0x08、0x0B、0x0C、0x0E–0x1F | 没有合法写法 | 写成 `` 也非法 | 写成 `` 也非法 |
命名空间,以及那条什么都查不到的查询
命名空间把一个前缀绑定到一个 URI,确立身份的是那个 URI。前缀只是任意的局部简写:只要 URI 相同,`<soap:Envelope xmlns:soap="...">` 和 `<s:Envelope xmlns:s="...">` 就是同一个元素;而两份文档用同一个前缀指向不同 URI,则毫无关系。URI 是标识符而不是地址,没有人会去拉取它,它也不需要可解析。
默认命名空间声明 `xmlns="urn:example:catalog"` 作用于该元素及其不带前缀的后代元素。它不作用于属性。不带前缀的属性永远不属于任何命名空间。这个不对称第一次遇到确实反直觉,也解释了相当一部分令人困惑的 schema 报错:在下面的例子里,`book` 属于 catalog 命名空间,而它的 `id` 属性不属于。
实际后果就是那条无声返回空结果的查询。XPath 1.0 没有默认命名空间的概念:表达式里不带前缀的名字意味着「不属于任何命名空间」。所以在带默认命名空间的文档里,`//book` 匹配不到 `<book>`,因为这是两个不同的名字。正确的修法是在 XPath 求值器里注册一个前缀,写成 `//c:book`。权宜之计 `//*[local-name()='book']` 完全忽略命名空间,同时也会匹配到毫不相干词汇表里的 `book` —— 探索时可以,生产代码里不行。
<?xml version="1.0" encoding="UTF-8"?>
<catalog xmlns="urn:example:catalog" xmlns:m="urn:example:meta">
<book id="b1" m:added="2026-03-01">
<title>Sword & Honour</title>
<price currency="GBP">12.99</price>
</book>
</catalog>
解析出的身份:
catalog、book、title、price -> {urn:example:catalog}名字
m:added -> {urn:example:meta}added
id、currency -> 不属于任何命名空间
针对这份文档的 XPath:
//book -> 空;这里的 "book" 指无命名空间的 book
//c:book 把 c 绑定到 urn:example:catalog -> 匹配到 book 元素
//*[local-name()='book'] -> 匹配到 book,也会匹配其他词汇表的 book
//book/@id -> 同样的原因,也会失败
//c:book/@id -> 匹配到该属性;注意 @id 不需要前缀混合内容、空白,以及为什么格式化不是免费的
在 JSON 里,词法单元之间的空白没有意义,格式化器想加多少都行。XML 没有这个保证,因为 XML 文档可以包含混合内容:一个元素同时装着文本和子元素。在 `<p>Hello <b>world</b>!</p>` 里,`Hello ` 和 `!` 是文本节点,`<b>` 前面那个空格属于数据本身。
会给子元素加缩进的美化器,会在它们之间插入纯空白文本节点。对于只含元素的内容 —— 配置文件、SOAP 信封 —— 没人会察觉,因为没有消费方去看这些节点。但对混合内容来说,它改变了文档:`<p>Hello<b>world</b></p>` 重新缩进成三行之后,原本没有空白的地方多了换行和空格,渲染出来的文本就不一样了。好的格式化器会识别混合内容并把这些元素保持在一行;不是所有格式化器都会。
实用规则:阅读 XML 时尽管重新排版,但要把排版后的版本当作一个视图,而不是文档本身。如果这份 XML 带签名,就完全不要重新排版。XML 签名覆盖的是规范化之后的形式,而规范化虽然会归一某些内容,却不会归一混合内容内部的空白 —— 给已签名的文档重新缩进,是让签名失效的可靠方法。
Schema 语言,以及哪一种回答你的问题
DTD 是最早的一种,内建在 XML 自身之中。它能约束元素嵌套、声明实体和属性默认值,但没有数据类型、不理解命名空间,语法也不是 XML。它主要存活在较老的词汇表里,以及那些让 XXE 成为可能的实体声明里。
XSD(W3C XML Schema)是工业标准,几乎所有企业集成说「schema」指的就是它。它理解命名空间,拥有被其他规范广泛借用的丰富数据类型系统,支持类型派生和替换组。它同时也啰嗦、学习曲线陡峭,而且要表达「这两个元素恰好出现一个」相当别扭。XSD 1.1 增加了断言和条件类型指派,补上了部分缺口,但工具支持比 1.0 薄。
| 语言 | 理解命名空间 | 数据类型 | 最擅长 |
|---|---|---|---|
| DTD | 否 | 无 | 遗留词汇表、实体声明 |
| XSD 1.0 | 是 | 丰富,被广泛复用 | 企业集成;默认期待的那一种 |
| XSD 1.1 | 是 | 丰富,另有断言 | 条件约束,前提是工具支持 |
| RELAX NG | 是 | 借用 XSD 数据类型 | 可读的文法、文档类格式 |
| Schematron | 是 | 不适用 —— 它是规则而非文法 | 共现规则与人类可读的错误消息 |
XXE 与实体膨胀
XML 的实体机制允许文档声明一个实体,其替换文本从某个 URI 获取。如果解析器响应这个声明,那么攻击者提供的文档就能读取本地文件、访问解析器可见而攻击者不可见的内网端点,并通过第二次请求把内容外传。这就是 XML 外部实体注入(XXE),基本上每种语言的默认 XML 栈都在某个时期出现过它。
防御手段不是输入过滤,而是解析器配置,而且必须作用在解析器而不是文档上。能完全禁用 DTD 处理就禁用;不能的话,至少禁用外部通用实体、外部参数实体和外部 DTD 加载。Java 里在 factory 上设置 `disallow-doctype-decl`;Python 里用 `defusedxml` 而不是标准库解析器;.NET 里把 `XmlResolver` 设为 null;PHP + libxml 里关闭实体替换。其中若干栈的新版本默认已经安全,但「新版本」这个前提在这句话里承担了实际重量,而安全默认并不普遍。
实体膨胀是与之相关的可用性问题。「十亿笑声」攻击嵌套十层实体,每层引用上一层十次,于是几百字节展开成几 GB 并耗尽内存。它不需要任何外部访问,所以一个禁用了外部实体、却仍然处理内部 DTD 的解析器依然暴露。多数成熟解析器现在会限制展开深度和展开总量;请去确认你的解析器确实如此,不要假定。
<?xml version="1.0"?>
<!DOCTYPE order [
<!ENTITY leak SYSTEM "file:///etc/passwd">
]>
<order>
<note>&leak;</note>
</order>
会解析外部实体的解析器,会把该文件的内容替换进 <note>。
如果应用把解析后的值回显出来,或者转发到任何地方,
这个文件就已经离开了主机。
不需要回显的变体,会在外部 DTD 中用参数实体拼出一个
包含文件内容的 URL 并发起请求 —— 数据通过请求本身外泄。
文档本身没有任何格式问题。修复完全在解析器一侧:
Java factory.setFeature(
"http://apache.org/xml/features/disallow-doctype-decl", true)
Python 使用 defusedxml
.NET reader 设置:DtdProcessing.Prohibit,XmlResolver = null
libxml2 不要开启实体替换;不加载任何网络实体要点回顾
- 把两个问题分开:不带 schema 的校验器只报告良构性,所以当文档能解析、对接方却拒收时,去找他们要 XSD。
- XML 只定义了五个实体,请用 ` ` 这样的数字引用代替 ` `;并记住除制表符、换行、回车之外,0x20 以下的控制字节即便写成字符引用也非法。
- XPath 查不到结果时,首先怀疑默认命名空间 —— 绑定一个前缀并使用它,不要在生产代码里退化成 `local-name()`。
- 不要给已签名的 XML 或含混合内容的 XML 重新排版;把美化后的版本当作文档的一个视图,而不是文档本身。
- 凡是读取非自己撰写内容的解析器,都要关闭 DTD 处理和外部实体解析,并确认解析器对实体展开设有上限。