YAML 不是"更漂亮的 JSON"

常见描述——"YAML 对人友好,JSON 对机器友好"——掩盖了真正的区别。YAML 是一个 80 多页的规范,具有隐式类型强制、锚点、标签、合并键和多文档支持。JSON 是一个 1 页的规范,没有歧义。

这个区别很重要,因为 YAML 的复杂性造成了 JSON 中不存在的类型强制陷阱安全漏洞有损转换边界

挪威问题:隐式类型强制

YAML 最臭名昭著的设计缺陷:裸值基于模式匹配被隐式强制转换为类型化值。

布尔地狱

在 YAML 1.1(直到最近大多数工具仍在使用)中,以下值全部被解析为布尔值 true

yaml
# 这些在 YAML 1.1 中全部变成布尔值
country_code: NO     # 挪威 ISO 代码 → false(!)
answer: yes          # → true
enabled: on          # → true
flag: TRUE           # → true
value: y             # → true

"挪威问题":一个国家代码列表中 NO(挪威)静默变为 false

yaml
countries:
  - DK    # 字符串 "DK"
  - FI    # 字符串 "FI"  
  - NO    # 布尔值 false(!)
  - SE    # 字符串 "SE"

解析后:["DK", "FI", false, "SE"]

数值强制

yaml
version: 1.0     # 浮点数 1.0,非字符串 "1.0"
zipcode: 01234   # 八进制 668 在 YAML 1.1(!),字符串在 1.2
port: 0755       # 八进制 493 在 YAML 1.1(!),字符串在 1.2
time: 12:30      # 六十进制 750 在 YAML 1.1(!),字符串在 1.2

YAML 1.1 vs 1.2:关键差异

YAML 1.1 解释 YAML 1.2 解释
yes / no 布尔值 字符串
on / off 布尔值 字符串
y / n 布尔值 字符串
0755 八进制整数 (493) 字符串
1:30 六十进制 (90) 字符串
true / false 布尔值 布尔值
null / ~ Null Null

YAML 1.2 通过将布尔值限制为仅 true/false,整数限制为仅十进制表示法,修复了最严重的强制陷阱。但大多数 YAML 库仍默认使用 YAML 1.1 行为(包括 PyYAML、Ruby 的 Psych 和旧版 js-yaml)。

修复方案:始终引用可能歧义的值

yaml
# 安全:显式引用的字符串
country_code: "NO"
version: "1.0"
port: "0755"
enabled: "yes"

安全:YAML 反序列化攻击

YAML 的标签系统允许指定任意类型,在很多语言中意味着任意代码执行

Python PyYAML:通过标签执行代码

yaml
# 危险:使用 yaml.load() 加载时执行 os.system("rm -rf /")
!!python/object/apply:os.system ["rm -rf /"]
python
import yaml

# 脆弱:yaml.load() 使用默认 Loader 会处理标签
data = yaml.load(malicious_yaml)  # 任意代码执行!

# 安全:yaml.safe_load() 忽略自定义标签
data = yaml.safe_load(yaml_string)  # 仅基本类型

始终使用 safe_load() / SafeLoader。不带显式 Loader 的 yaml.load() 是最常见的 YAML 漏洞模式。

YAML 炸弹(十亿笑声攻击)

YAML 锚点支持指数级展开:

yaml
a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"]
b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a]
c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b]
d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c]
e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d]

五层 9× 展开:9⁵ = 59,049 个字符串,仅来自几行代码。这可以耗尽内存并使解析器崩溃。

防御:在解析器配置中设置递归限制和最大展开大小。

JSON 免疫

JSON 没有标签系统、没有锚点、没有在解析过程中执行代码的机制。这是 JSON 简单性的安全优势。

每个转换方向的信息损失

YAML → JSON(信息丢失)

YAML 特性 转换后的行为
注释 (#) 丢失 — JSON 中无表示方式
锚点和别名 展开 为重复数据(增加大小)
合并键 (<<) 展开 为扁平对象
多文档 (---) 丢失 — JSON 无多文档概念
自定义标签 (!!type) 丢失 或导致错误
多行标量 (|>) 转换为带 \n 的单个字符串
文档标记 (---...) 丢失
键顺序 实现相关 — JSON 规范不保证顺序

JSON → YAML(无损,但有选择)

JSON → YAML 是无损的(YAML 是 JSON 的超集)。但转换涉及选择:

  • 缩进风格(2 vs 4 空格)
  • 流式 vs 块式风格(数组和对象)
  • 引号策略(全部引用,还是仅在需要时引用)
  • 是否对重复子树使用锚点

解析库安全姿态

语言 默认行为 安全模式 YAML 版本
PyYAML Python 不安全(标签执行) safe_load() 1.1
ruamel.yaml Python 往返安全 可配置 1.2
strictyaml Python 安全(无标签、无隐式类型) 始终安全 子集
js-yaml JavaScript 安全(默认忽略标签) 默认 1.2(v4 起)
go-yaml Go 安全(无任意执行) 默认 1.2(v3)
SnakeYAML Java 不安全(处理标签) SafeConstructor 1.1
Jackson YAML Java 可配置 通过 ObjectMapper 1.1

建议

  • Python:配置文件用 strictyaml(消除所有隐式强制),往返编辑用 ruamel.yaml,永远不用不带 SafeLoaderyaml.load()
  • JavaScriptjs-yaml v4+ 默认安全
  • Gogopkg.in/yaml.v3 遵循 YAML 1.2,默认安全
  • Java:永远不要对不受信任的输入使用 new Yaml().load() — 使用 SafeConstructor

何时 TOML 是更好的选择

TOML(Tom's Obvious Minimal Language)专为配置文件设计,避免了 YAML 的复杂性和 JSON 缺少注释的问题:

toml
# TOML:显式类型,无强制转换,支持注释
[server]
host = "localhost"
port = 5432
enabled = true

[database]
name = "myapp"
pool_size = 20

[[routes]]
path = "/api/users"
method = "GET"

[[routes]]
path = "/api/orders"
method = "POST"

YAML vs JSON vs TOML 决策矩阵

标准 YAML JSON TOML
人类可读性 好(如果简单) 中等
机器解析速度 中等
注释
类型安全 差(隐式强制) 好(显式) 好(显式)
安全风险 高(标签、炸弹) 极低 极低
规范复杂度 80+ 页 1 页 ~20 页
嵌套结构 无限深度 无限深度 超过 3 层时不便
行业采用 Kubernetes, CI/CD, Ansible APIs, package.json Rust (Cargo.toml), Python (pyproject.toml)

使用 YAML 当

  • 生态系统要求(Kubernetes、GitHub Actions、Ansible)
  • 需要多文档文件
  • 需要锚点/别名实现 DRY 配置

使用 JSON 当

  • 服务间数据交换(API)
  • 消费者是 JavaScript/浏览器
  • 需要模式验证(JSON Schema 已成熟)
  • 安全性至关重要(无代码执行风险)

使用 TOML 当

  • 编写应用配置
  • 想要注释但不要 YAML 的类型强制风险
  • 嵌套浅(≤3 层)
  • 生态系统支持(Rust、Python 打包、Hugo)

正确的转换代码

Python(安全)

python
import json
from ruamel.yaml import YAML

yaml = YAML()
yaml.preserve_quotes = True

# YAML → JSON(安全,YAML 1.2)
with open('config.yaml') as f:
    data = yaml.load(f)
json_str = json.dumps(data, indent=2, ensure_ascii=False)

# JSON → YAML
with open('data.json') as f:
    data = json.load(f)
with open('output.yaml', 'w') as f:
    yaml.dump(data, f)

JavaScript / Node.js

javascript
import { load, dump } from 'js-yaml';
import { readFileSync, writeFileSync } from 'fs';

// YAML → JSON
const yamlContent = readFileSync('config.yaml', 'utf8');
const data = load(yamlContent);  // js-yaml v4:默认安全
const jsonStr = JSON.stringify(data, null, 2);

// JSON → YAML
const jsonContent = readFileSync('data.json', 'utf8');
const parsed = JSON.parse(jsonContent);
const yamlStr = dump(parsed, { indent: 2, lineWidth: 120 });

Go

go
package main

import (
    "encoding/json"
    "gopkg.in/yaml.v3"
)

func yamlToJSON(yamlBytes []byte) ([]byte, error) {
    var data interface{}
    if err := yaml.Unmarshal(yamlBytes, &data); err != nil {
        return nil, err
    }
    return json.MarshalIndent(data, "", "  ")
}

CLI 工具

bash
# yq:YAML 瑞士军刀
yq -o=json config.yaml > config.json
yq -P config.json > config.yaml

# Python 单行命令
python -c "import sys,yaml,json; print(json.dumps(yaml.safe_load(sys.stdin),indent=2))" < config.yaml

生产中的常见陷阱

1. 缩进错误是静默的

YAML 缩进决定结构。一个空格的错误会改变含义:

yaml
# 预期:嵌套在 server 下
server:
  host: localhost
  port: 5432

# Bug:port 是 server 的兄弟节点(缩进错误)
server:
  host: localhost
port: 5432

2. Tab vs 空格

YAML 禁止使用 Tab 缩进。一个 Tab 字符会导致解析错误,但很多编辑器将 Tab 和空格显示为相同外观。

3. 看起来像其他类型的未引用字符串

yaml
version: 3.10    # 浮点数 3.1(末尾零被丢弃!)
version: "3.10"  # 字符串 "3.10"(正确)

4. 多行字符串尾部换行

yaml
# | 保留尾部换行
content: |
  hello

# |- 去除尾部换行  
content: |-
  hello

||- 之间的区别对模板、脚本以及任何尾部换行会改变行为的值都很重要。

总结

YAML 和 JSON 不是语法不同的可互换格式。它们有根本不同的安全模型、类型系统和信息容量。YAML→JSON 方向的转换是有损的(注释、锚点、标签、多文档),JSON→YAML 方向的转换需要做出选择(风格、引号、缩进)。

关键原则:

  • 对不受信任的 YAML 始终使用安全解析函数(safe_loadSafeConstructor
  • 引用可能被隐式强制转换的值("NO""3.10""yes"
  • 优先选择消除布尔强制陷阱的 YAML 1.2 库
  • 对于生态系统不强制要求 YAML 的新配置文件,考虑 TOML
  • 将 YAML→JSON 转换视为有损操作,并验证输出