XML 指南

XML 校验与格式化:良构、有效,以及安全

良构与 schema 有效性的区别、真正要紧的那五个转义、命名空间如何让 XPath 失灵、为什么美化排版可能改变语义,以及如何关掉 XXE。

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 只预定义了五个实体:`&lt;`、`&gt;`、`&amp;`、`&quot;` 和 `&apos;`。其余的一切 —— `&nbsp;`、`&copy;`,以及大家习以为常的几百个 HTML 实体 —— 在 XML 里都是未定义的,除非某个 DTD 声明过它们。这是「浏览器能渲染、解析器却拒绝」最常见的原因:文档是按 HTML 的习惯写出来的。改用 `&#160;` 这样的数字字符引用,它任何时候都有效。

哪些转义是必需的取决于上下文。在字符数据里,必须转义 `<` 和 `&`。除了 `]]>` 这个特定序列之外,`>` 不需要转义,不过顺手转义也无害、也很常见。在属性值里,必须转义 `<`、`&`,以及界定该值的那种引号;另一种引号可以字面出现,这就是为什么 `title='He said "no"'` 是合法的。

字符引用写法在元素文本中在属性值中
`<``&lt;`必需必需
`&``&amp;`必需必需
`>``&gt;`仅在 `]]>` 内部不必需
`"``&quot;`不必需值用双引号时必需
`'``&apos;`不必需值用单引号时必需
`&nbsp;` 及其他 HTML 实体XML 中未定义改用 `&#160;`改用 `&#160;`
控制字节 0x00–0x08、0x0B、0x0C、0x0E–0x1F没有合法写法写成 `&#7;` 也非法写成 `&#7;` 也非法

命名空间,以及那条什么都查不到的查询

命名空间把一个前缀绑定到一个 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 &amp; 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 的解析器依然暴露。多数成熟解析器现在会限制展开深度和展开总量;请去确认你的解析器确实如此,不要假定。

XXE 载荷的形状
<?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 只定义了五个实体,请用 `&#160;` 这样的数字引用代替 `&nbsp;`;并记住除制表符、换行、回车之外,0x20 以下的控制字节即便写成字符引用也非法。
  • XPath 查不到结果时,首先怀疑默认命名空间 —— 绑定一个前缀并使用它,不要在生产代码里退化成 `local-name()`。
  • 不要给已签名的 XML 或含混合内容的 XML 重新排版;把美化后的版本当作文档的一个视图,而不是文档本身。
  • 凡是读取非自己撰写内容的解析器,都要关闭 DTD 处理和外部实体解析,并确认解析器对实体展开设有上限。

继续了解相关检查与工具