2529 字
约 8 分钟
14
S10-A17 · JSON 序列化的默认陷阱:什么被悄悄改了

一个服务的配置导出接口,导出的 JSON 里 timeout 从 30 变成了 30.0,而导入端把它当字符串处理,于是报"类型不合法"。同一份代码在另一个环境没出问题——因为那边的值是整数。而根源是 json.dumps 对浮点的默认处理,加上"30 被读成了 float"。

Python 的 JSON 处理在"默认行为"上有一堆细节:浮点、NaN、非 ASCII、日期、整数键、以及 __eq__ 的自定义对象。而"悄悄改"比"报错"更难查:报错会立刻指向问题,改值只会让下游算出不同的结果,而两边各自的日志都"正常"。
这些默认值在演示时都"能用",在真实数据上会变成"数据被悄悄改了"。


1. 五个默认行为

行为 默认值
非 ASCII ensure_ascii=True(转义)
浮点表示 最短往返表示(30.0 保留 .0)
NaN / Infinity 输出非法 JSON
键类型 非字符串键被强制转字符串
缩进/分隔符 无空格,紧凑

第三项最严重:json.dumps(float('nan')) 输出 NaN,而 NaN 不是合法 JSON第三项的来源很实际:数据库里的 NULL、缺失值、除零结果都可能变成 NaN,而它一路传到序列化层才爆。所以"NaN 出现在数据里"本身是一个应当被拦住的信号。
,很多解析器会拒绝。默认没有 allow_nan=False。

第四项在"整数键"上很常见:{1: 'a'} 序列化成 {"1": "a"},反序列化回来键是字符串,于是 d[1] 拿不到。

import json
print(json.dumps({"v": 30.0}))            # {"v": 30.0}  ← 不是 30
print(json.dumps({"v": float("nan")}))    # {"v": NaN}   ← 非法 JSON
print(json.dumps({1: "a"}))               # {"1": "a"}   ← 键类型变了
print(json.dumps({"名": "值"}))            # {"\u540d": ...}  ← 转义

2. 类型往返的丢失

类型 往返后
tuple list
datetime 直接报错
Decimal 报错
set 报错
int(超大) 可能被 JS 截断

默认支持的只有 dict/list/str/int/float/bool/None,所以"错了就报错"其实是好事:报错会强迫你显式定义格式(ISO 8601 还是时间戳),而隐式转换会让格式在各处不一致。
第二项要显式处理:datetime 不在默认支持的六种类型里,需要 default= 或自定义编码器。

第五项在"跨语言"时出现:Python 的 int 可以任意大,而 JavaScript 的 number 只有 53 位精度,于是 20 位 ID 传过去就变了值。所以大整数应当序列化成字符串。

// 显式编码:日期、Decimal、大整数
def dumps_ext(o):
    return json.dumps(o, default=lambda x: (
        x.isoformat() if hasattr(x, "isoformat") else
        str(x) if isinstance(x, (Decimal, set, frozenset)) else
        raise_type(x)
    ), ensure_ascii=False, allow_nan=False, sort_keys=True)
// 大整数:序列化时先转字符串,或统一用字符串字段

3. sort_keys 与确定性

参数 影响
sort_keys=True 输出稳定,可用于签名
默认(无排序) 依赖插入顺序,不稳定
separators 影响体积与哈希
indent 影响体积与可读

第一项对"接口签名/缓存键"是必须的:不排序时,同一个字典在两次构造里可能顺序不同这个"顺序不同"在 Python 3.7+ 里不会因哈希随机化而变(字典保插入序),但不同代码路径构造出的字典顺序确实会不同——比如一个分支先放 a 后放 b,另一个反过来。所以"同一份数据"经不同路径到达时,字节不同。
,于是哈希不同,签名失效。

第三项同理:separators=(',', ':') 与默认的 (', ', ': ') 会产生不同的字节,而签名与哈希是对字节算的。

// 用于签名/哈希的稳定输出(三件必须固定)
payload = json.dumps(obj, sort_keys=True, ensure_ascii=False,
                     separators=(",", ":"), allow_nan=False)
sig = hmac.new(key, payload.encode("utf-8"), sha256).hexdigest()
// 任何一项变了 → 签名校验失败 → 排查会非常痛苦

4. 反序列化的安全问题

风险 说明
超大输入 内存耗尽
深度嵌套 递归耗尽栈
重复键 后者覆盖前者
类型混淆 "1" 与 1

第三项还有一个"签名"版本:如果签名是对"解析后的对象"算的,重复键会让签名通过而值是攻击者想要的。所以签名必须对原始字节算,而不是对解析结果算。
第三项容易被忽略:{"a":1,"a":2} 在多数实现里取后者,而业务可能期望"报错"。这在与外部系统对接时会造成"值被悄悄改"。

第二项在恶意输入上是真实的 DoS 途径:json.loads 对深嵌套没有默认限制第二项的另一面是"合法但极端"的输入:某些导出数据真有几十层嵌套,此时该调高上限而不是直接拒绝。所以上限应当是"配置项"而不是硬编码常量。
,几千层嵌套就能让递归爆栈。

// 反序列化的三道限制
def safe_loads(s: str, max_bytes: int = 1_000_000, max_depth: int = 32):
    if len(s.encode()) > max_bytes:
        raise ValueError("payload too large")
    # 深度检查:简易做法是先数括号层数,或用 parse_float/parse_int 钩子计数
    ...
// 关键:外部输入一律加上限;内部输入可用默认

还有一个"性能"层面的默认:json.loads 是纯 Python 实现的,在大负载下会成为瓶颈。换成 orjson/ujson 通常有 3–10 倍提升,而代价是"行为略有差异"(如对非法输入的处理),所以要配对测试。

// 性能替换:先测行为一致性,再换
import json, orjson, time
data = {"a": [1, 2, 3], "b": "中文", "c": 30.0}
assert orjson.dumps(data) == json.dumps(data, ensure_ascii=False, separators=(",", ":")).encode()
// 不一致的项:NaN、非字符串键、Decimal —— 需要显式处理

5. 与其他格式的边界

场景 建议
配置 JSON 够,但 YAML 更可读
日志 一行一个 JSON(NDJSON)
大数值 ID 序列化为字符串
二进制 不用 JSON(用 base64 会膨胀 33%)

第二项(日志)有个细节值得说:NDJSON 的每一行必须"单行",所以序列化时不能带 indent,且字符串里的换行要被转义(JSON 默认会转义为 \n,这点是对的)。
第四项的理由很直接:base64 让体积增加三分之一,而且解析要多一步。传二进制应当用专门格式或分段上传。

协议设计上还有一条经验:不要把"有没有值"与"值为 null"混为一谈。JSON 里 {"a": null} 与 {} 是两件事,而很多客户端会把两者都当成"没有"。所以字段的"可缺省"语义要写进契约。

// 三种"无值"必须在契约里区分
{}                 字段不存在(可能表示"没查")
{"a": null}        字段存在但为空(明确为空)
{"a": ""}          空字符串(类型是字符串)
// 反例:三者在客户端都被当成"没有" → 业务无法区分"未设置"与"设置为空"

6. 排错:五种"JSON 出问题"

现象 成因 处理方向
30 变成 30.0 浮点表示 显式 int() 或格式化
解析报"非法 JSON" NaN/Infinity allow_nan=False
中文变 \uXXXX ensure_ascii 默认 设 False
键取不到 整数键变字符串 统一字符串键
签名偶尔失败 无 sort_keys 固定三项

第五项最耗时间:"偶尔"失败会被当成偶发问题,而它其实是"字典顺序不同"这个确定性原因。第四项(键取不到)的排查方式:打印反序列化后的 type(d.keys())——如果全是 str,那就是原始字典用了非字符串键。

最后一条实践建议:把"序列化参数"集中到一处工具函数,不要在各处直接调 json.dumps。因为五个参数一旦分散,就一定会出现"某处忘了 ensure_ascii=False"这类不一致——而它只在含中文的响应上暴露。

// 统一出口:所有对外 JSON 都走它
from app.util.jsonx import dumps  # 内部固定 sort_keys/ensure_ascii/allow_nan
body = dumps(payload)             # 参数不可改,避免不一致
// 规则:除 util 之外,禁止直接 import json 做序列化(用 lint 或评审约束)

7. 边界:什么情况不必精细控制

场景 为什么 做法
内部调试输出 不经解析 随意
前端展示的一次性数据 不参与计算 默认即可
已有严格的 schema 校验 错误会被拦 默认 + 校验
只在自己的服务间传递且类型简单 无跨语言 默认即可

最后一条与"协议"有关的边界:一旦 JSON 成了对外契约,五个参数就成了契约的一部分。改 ensure_ascii 或 separators 会让"字节不同"——如果对方做了字节级校验,这次改动就是破坏性变更。
判断口径一句话:这份 JSON 会不会被"另一个实现"解析,或参与"签名/哈希"。会,就必须固定五个参数;只在 Python 内部来回,默认够用。


8. 自测

  1. 五个默认行为里,哪一个会产生"非法 JSON"?为什么默认允许它?
  2. 整数键序列化后会发生什么?为什么会造成"键取不到"?
  3. 为什么"大整数"应当序列化成字符串?这与 JavaScript 的什么限制有关?
  4. 用于签名的输出必须固定哪三项?不固定会发生什么?
  5. 反序列化的"重复键"风险是什么?深嵌套为什么是 DoS 途径?
  6. 判断"是否需要精细控制"的那个问题是什么?
S10-A17 · JSON 序列化的默认陷阱:什么被悄悄改了
https://www.clxhxhhr.top/posts/6514/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。