XML解析器在遇到非法字符时通常会直接抛出异常,导致整个消息处理流程中断。这个问题在对接第三方接口、采集用户输入或是读取旧系统遗留数据时尤为常见。要彻底解决它,首先需要搞清楚XML规范到底把哪些字符划入了非法范围,然后才能针对不同的数据来源和业务场景选择合适的清理手段。

哪些字符在XML中属于非法字符
XML 1.0规范对合法字符的界定非常严格。可用的字符范围包括制表符(Tab,U+0009)、换行符(LF,U+000A)、回车符(CR,U+000D)、以及从U+0020到U+D7FF的大部分Unicode字符,再加上U+E000到U+FFFD以及U+10000到U+10FFFF的增补区域。换句话说,绝大多数控制字符都不在合法范围内,比如空字符(U+0000)、振铃符(U+0007)、垂直制表符(U+000B)以及U+0001到U+0008这些区间内的字符。
很多人误以为只要把尖括号、引号和&符号转义掉,XML就安全了。实际上这是两个不同层面的问题。转义解决的是标记语法冲突,而非法字符问题涉及字符集规范本身。举个例子,某个字段的值里含有一个U+0004控制字符,即使你把该字段完好地放在一个文本节点里,XML解析器仍然会拒绝处理,因为它根本不承认这个字符有资格出现在XML文档中。理解这个区别很关键,因为对语法符号用转义、对非法字符用过滤或编码,才是完整的防御策略。
另一个容易被忽视的问题是编码声明与实际字节序列不一致。文件头声明encoding=utf-8,但内容里混入了GBK编码的中文或者从Windows记事本带来的BOM字符,解析时也可能被判定为非法字符。此外,某些代理对(Surrogate Pair)单独出现,或者U+FFFE、U+FFFF这类非字符,同样会导致解析失败。处理前最好先确认数据在进入XML构建流程之前,已经完成了一轮字符集层面的清洗。
过滤非法字符的核心实现
最直接的做法是在生成XML之前,对字符串逐字符扫描,把不在合法范围内的字符移除或替换成空格。这种方案的优点是实现简单、性能开销可控,缺点是无法保留原始数据的完整性。如果后续业务需要还原原始字符串,单纯过滤就会丢失信息。下面给出一个Java版本的过滤函数,它会遍历字符串的每个字符,判断是否属于XML 1.0允许的范围。
public class XmlSanitizer {
public static String stripInvalidXmlChars(String input) {
if (input == null) {
return null;
}
StringBuilder sb = new StringBuilder(input.length());
for (int i = 0; i < input.length(); i++) {
char c = input.charAt(i);
if (isValidXmlChar(c)) {
sb.append(c);
} else {
sb.append(' ');
}
}
return sb.toString();
}
private static boolean isValidXmlChar(char c) {
return c == 0x9 || c == 0xA || c == 0xD ||
(c >= 0x20 && c <= 0xD7FF) ||
(c >= 0xE000 && c <= 0xFFFD) ||
(c >= 0x10000 && c <= 0x10FFFF);
}
}
这段代码在处理增补字符时存在一个细节问题。Java中的char是UTF-16编码单元,像emoji这类字符需要两个char来表示,范围判断时如果只处理单个char,会错误地把合法的增补字符拆分成两个看似非法的代理项。更严谨的做法是使用codePointAt方法遍历整型码点,再对码点进行范围判断。下面的改进版本能正确处理全Unicode字符集。
public class XmlSanitizerV2 {
public static String sanitize(String input) {
if (input == null) return null;
StringBuilder sb = new StringBuilder(input.length());
int i = 0;
while (i < input.length()) {
int cp = input.codePointAt(i);
if (isValidCodePoint(cp)) {
sb.appendCodePoint(cp);
}
i += Character.charCount(cp);
}
return sb.toString();
}
private static boolean isValidCodePoint(int cp) {
return cp == 0x9 || cp == 0xA || cp == 0xD ||
(cp >= 0x20 && cp <= 0xD7FF) ||
(cp >= 0xE000 && cp <= 0xFFFD) ||
(cp >= 0x10000 && cp <= 0x10FFFF);
}
}
Python环境下可以用正则表达式快速实现相同的过滤效果。Python的内置re模块支持Unicode范围匹配,配合sub方法就能把非法字符统一替换掉。需要注意的是,Python 3中的字符串本身就是Unicode码点序列,直接用ord判断或者使用预编译的正则都可以。下面这个例子把非法控制字符替换为空字符串,保证剩余内容仍然连续。
import re
_ILLEGAL_XML_CHARS_RE = re.compile(
u'[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]'
)
def sanitize_xml_text(value):
if value is None:
return None
return _ILLEGAL_XML_CHARS_RE.sub('', value)
这个正则表达式中\x00-\x08覆盖了除Tab、LF、CR之外的控制字符,\x0b和\x0c分别是垂直制表符和换页符,\x0e-\x1f则覆盖剩下的控制字符区间。\ud800-\udfff用于过滤未配对的代理项,\ufffe和\uffff是两个非字符。直接把非法字符删除而不是替换成空格,在文本节点中通常更符合直觉,但如果是用在属性值里,连续删除可能导致相邻单词粘连,需要根据具体场景决定替换策略。
转义与CDATA的适用边界
对于<、>、&、单引号和双引号这五个字符,XML提供了标准转义机制。它们本身并不是非法字符,只是在特定位置上会被解析器当作标记语法的一部分。文本节点中至少需要转义<和&,而属性值中还需要额外处理单引号、双引号以及制表符、换行符等空白字符。常见做法是把换行符转义为 ,制表符转义为	,避免属性值在解析后被规范化。
CDATA区段看起来是一个省事的方案,把任意内容塞进<![CDATA[ ]>就能避免大部分转义工作。但CDATA只对文本节点有效,不能用在属性值里,而且它解决不了真正的非法字符问题。如果CDATA内容里包含U+0001这样的控制字符,解析器照样报错。更麻烦的是,CDATA本身不能嵌套,一旦数据里出现]]>序列,就必须手动拆分CDATA或者先做一次替换。对于可能包含任意二进制残留的不可信数据,CDATA并不是一个可靠的兜底方案。
当数据可能包含任意二进制内容,或者需要无损保留原始字节时,Base64编码是更稳妥的选择。把原始字节序列编码成只包含大小写字母、数字以及加号和斜杠的字符串,就完全绕开了XML字符集的限制。接收方拿到数据后再进行解码还原。这种方式的代价是数据体积膨胀约33%,而且编码后的字符串不具备可读性。它适合用在图片、加密后的密文、数字签名等场景,不适合用在需要人工阅读或者需要在XML内部进行文本检索的字段上。
不同语言环境下的处理差异
C# 开发者在 .NET 平台上处理XML非法字符时,可以利用XmlConvert类提供的方法。其中XmlConvert.IsXmlChar和XmlConvert.IsXmlSurrogatePair能够准确判断字符是否被XML规范接受。不过这两个方法只能用于判断,批量过滤时仍然需要自己写循环。.NET Framework 4.0之后,SecurityElement.Escape方法可以用于转义XML中的特殊字符,但同样不处理控制字符的过滤,需要结合使用。
如果使用C#构建XML文档,推荐通过XmlWriter或者XElement来写入数据,这些API在底层会自动处理特殊字符的转义。但在调用XElement.Parse或者XmlDocument.LoadXml之前,传入的字符串中如果包含非法控制字符,依然会抛出XmlException。所以过滤步骤必须发生在数据进入解析API之前,这和语言无关,是所有XML处理场景的通用原则。
JavaScript在浏览器端处理XML时,可以使用DOMParser来解析字符串。如果字符串中含有非法字符,不同浏览器的行为并不完全一致,有的会静默替换,有的会直接抛出异常。因此前端在把数据提交给后端之前,最好也做一轮过滤。一个简单的实现是遍历字符串,用charCodeAt判断每个字符的码点,把非法字符替换成空格或者删除。处理增补字符时要注意使用codePointAt而不是charCodeAt,否则高代理项和低代理项会被分开处理,导致合法的emoji被误伤。
除了手动过滤,很多XML库本身提供了配置选项来容忍非法字符。例如Python的lxml库在解析时可以通过recover=True选项尝试恢复解析,但这个选项并不能保证所有非法字符都被正确处理,而且可能掩盖真正需要关注的编码问题。更可靠的做法还是在数据源头做好清洗,而不是依赖解析器的宽容模式。