Python JSON 校验避坑:重复字段、NaN 和 1e400 为什么会漏检
配置文件可以被 json.loads() 读出来,是否就能放心交给业务代码?下面用一个小实验检查这个假设。示例只使用 Python 标准库,不需要访问云服务,也不修改现有配置。
本文以“导入一个 JSON 配置对象”为场景:要求字段名没有歧义,浮点数是有限值,输入采用 UTF-8,原始内容不超过 64 KiB。这是一组明确的应用约束,不是声称实现了通用的 JSON 安全校验器。
一、先观察默认行为
在本次 Python 3.13.11 环境中运行:
import json
import math
print(json.loads('{"retries": 1, "retries": 5}'))
print(math.isnan(json.loads('NaN')))
print(math.isinf(json.loads('1e400')))
输出依次是 {'retries': 5}、True、True。第一项覆盖了较早的同名字段;第二项接收了特殊常量;第三项把一个指数形式的数字转成了无穷大。
这里有三个不同的问题。RFC 8259 建议对象内的名称唯一,但没有要求所有解析器采用相同的重复名称处理方式;它也没有把 NaN、Infinity 纳入数字语法。1e400 则符合数字语法,只是超出了这里浮点表示的范围。应用选择拒绝它,是数值范围策略,不能把它与语法错误混为一谈。
如果配置里先写 retries: 1,后面又重复写成 retries: 5,只检查解析后的字典,就已经看不到被覆盖的那一项。排障时应保留原始输入的验证过程,而不是指望字典替自己恢复丢失的信息。
二、把约束放进解析入口
下面的入口接收 bytes,便于按传入字节数设置上限。示例中的 64 KiB 是这份小配置的策略值,需要按实际用途调整。
import json
import math
MAX_BYTES = 64 * 1024
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate object key")
result[key] = value
return result
def reject_constant(token):
raise ValueError("non-standard numeric constant")
def finite_float(token):
value = float(token)
if not math.isfinite(value):
raise ValueError("float outside finite range")
return value
def load_config(raw: bytes):
if not isinstance(raw, bytes):
raise TypeError("expected bytes")
if len(raw) > MAX_BYTES:
raise ValueError("configuration too large")
value = json.loads(
raw.decode("utf-8"),
object_pairs_hook=unique_object,
parse_constant=reject_constant,
parse_float=finite_float,
)
if not isinstance(value, dict):
raise ValueError("configuration must be an object")
return value
三个回调各有用途:object_pairs_hook 在键值对变成最终对象时检查重名,嵌套对象也会经过它;parse_constant 拦截三种特殊数字常量;parse_float 检查正常数字文本转换后是否仍为有限值。因此仅增加 parse_constant,还不足以拦住 1e400。
重复名称按解码后的字符串比较,所以 "a" 和 "\u0061" 也会被视为同一个名称。同名字段出现在两个不同对象中则可以接受,示例并没有设置一份跨对象的全局字段名黑名单。
另外,strict=True 在标准库解码器中主要约束字符串中的控制字符,并不会自动启用上面这组规则。不要只看参数的名字就推断其覆盖范围。
三、用会失败的输入验证入口
下面的断言可以紧接着上一段代码运行。它们验证应用应当拒绝的边界,而不只是证明正常示例能成功。
good = load_config(b'{"retries": 2, "timeout_s": 1.5}')
assert good == {"retries": 2, "timeout_s": 1.5}
assert load_config(b'{"left":{"x":1},"right":{"x":2}}')
bad_inputs = [
b'{"retries":1,"retries":5}',
b'{"nested":{"x":1,"x":2}}',
br'{"a":1,"\u0061":2}',
b'{"x":NaN}',
b'{"x":Infinity}',
b'{"x":-Infinity}',
b'{"x":1e400}',
b'{"x":-1e400}',
b'[]',
b'{"x":1} {"x":2}',
b'{"x":"\xff"}',
b' ' * (MAX_BYTES + 1),
]
for raw in bad_inputs:
try:
load_config(raw)
except ValueError:
pass
else:
raise AssertionError("invalid input was accepted")
尾部又拼一个对象、非法 UTF-8、输入超限,都在这组测试中。这里统一捕获 ValueError,因为标准库的语法错误和本例的解码错误也属于这一异常体系。对外提供接口时,可以返回稳定的“配置格式错误”提示,内部再区分失败原因;不要把包含令牌或连接信息的完整配置直接写进错误日志。
四、解析成功以后,继续验证业务字段
前面的入口仍会接受 {"retries": true}。布尔值是合法 JSON,但本例希望重试次数是 0 到 5 的整数。可以补上独立的字段校验:
def validate_retry_config(config):
if set(config) != {"retries", "timeout_s"}:
raise ValueError("unexpected or missing fields")
retries = config["retries"]
if type(retries) is not int or not 0 <= retries <= 5:
raise ValueError("retries must be an integer from 0 to 5")
timeout = config["timeout_s"]
if type(timeout) not in (int, float) or not 0 < timeout <= 30:
raise ValueError("timeout_s must be within (0, 30]")
return config
此处特意检查具体类型,防止把 Python 中的 True 当作整数 1 接收。字段白名单也能让 timeout_sec 这类拼写错误尽早暴露。规则是否允许额外字段、是否允许浮点形式的重试次数,应在接口约定中写明,再据此修改校验逻辑。
五、输出与资源边界也需要约定
写出 JSON 时,如果业务数据可能包含非有限浮点数,可以设置 json.dumps(data, allow_nan=False),让不适合交换的数值尽早失败。读入侧的钩子与写出侧的参数解决的是两个入口,不能互相替代。
这份代码也有清晰的边界:它没有解决二进制浮点的精度和下溢问题,没有为所有整数规定跨语言范围,也没有限制嵌套深度或实现流式解析。金额、超大标识符等字段需要另行约定表示方式;仅仅把 parse_float 换成 Decimal,也不能自动完成范围、序列化和业务规则的设计。
64 KiB 检查发生在函数已经收到字节对象之后,无法避免调用方先把巨大请求完整读进内存。实际接入上传或 HTTP 请求时,应在读取阶段同步设置大小上限,再交给解析入口;对于不可信输入,还应结合嵌套限制和资源隔离评估解析成本。
本次验证覆盖了正常对象、对象内重名、嵌套重名、转义名称重名、三个特殊常量、正负指数溢出、根节点类型、尾随内容、编码和大小边界,并额外检查了业务字段及输出拒绝非有限值的行为。
参考:Python 官方文档《json — JSON encoder and decoder》的参数与互操作性说明;RFC 8259 第 4、6、8.1 节。以上代码和测试为围绕配置导入场景编写的示例,文章由 AI 辅助整理,并在本地运行验证。
- 点赞
- 收藏
- 关注作者
评论(0)