复现自己系统或授权接口的前端请求时,参数看起来一样,校验结果却不同,应该先把计算前的输入保存成字节,找到第一个差异位置。对象相等、界面显示相同,都不能证明送入计算函数的字节相同。
这篇用完全虚构的本地协议,复现 8 类差异,再让 Node.js 和 Python 生成完全一致的输入。SHA-256 只用于核对实验结果,没有密钥,不能用于证明请求身份,也不是接口鉴权方案。
验证日期为 2026-09-08,环境为 Python 3.9.6、Node.js v24.19.0,无第三方依赖、不发送网络请求。
1. 先分清“解析结果”和“原始输入”
q=AI+agent 与 q=AI%20agent 按表单查询参数规则解析后,都能得到带空格的 AI agent。但是原始字符串一个长 10 字节,另一个长 12 字节。
URLSearchParams.toString() 会把空格编码成 +;encodeURIComponent() 编码空格得到 %20。采用哪个方法,取决于协议实际要求,不能只根据参数值相同就互换。字面量加号还需要与空格区分,本例统一编码为 %2B。MDN:URLSearchParams 序列化、MDN:encodeURIComponent。
重复键也是容易丢掉的信息。tag=python&tag=js 转成普通字典后,只剩一个 tag。顺序调换虽然仍包含两个值,原始字节也会改变。Python 可以用有序二元组列表传给 urlencode(),保留这种输入结构。Python:urllib.parse。
2. JSON 和 Unicode 也会改变字节
Python 的 json.dumps() 默认会转义非 ASCII 字符,并在部分分隔符后插入空格。使用 ensure_ascii=False, separators=(',', ':'),可以在本文限定的数据里与紧凑 JSON 对齐;这两个参数不是任意对象的跨语言规范化方案。Python:JSON 编码参数。
两个 JSON 文本解析为相同的对象,仍可能有不同的键顺序、空白和转义。JavaScript 的 JSON.stringify() 也有自己的属性访问顺序,以及对 undefined、非有限数值等的处理规则。排查时保留序列化后的文本,不要先重新解析、美化,再拿它当原始输入。MDN:JSON.stringify。
Café 中的最后一个字符,可以是单个 é,也可以是 e 后接组合重音。两种写法看起来接近,UTF-8 字节却不同。NFC 可以把这个例子归一,但只有协议明确采用它时才能这样处理;单方面加上归一化也可能制造不一致。MDN:Unicode normalize。
3. 一套可复现的虚构协议
本例约定查询参数和正文均由“有序字符串对”组成;重复项保留,字符串采用 NFC;查询参数空格采用 %20,非保留字符集合为字母、数字和 -._~;正文采用无额外空白的 JSON 数组。
计算输入为:版本字符串、换行、查询字符串、换行、正文,最后不追加换行,整体使用 UTF-8。这是本地实验约定,不应套用到其他接口。
先保存 parameter_bytes_demo.mjs:
import { createHash } from 'node:crypto';
// Fictional protocol: ordered string pairs, NFC, strict percent encoding.
const queryPairs = [
['q', 'AI + 爬虫'], ['tag', 'python'], ['tag', 'js'],
['word', 'Cafe\u0301'], ['punct', "!*'()~"],
];
const bodyPairs = [['name', 'Cafe\u0301'], ['page', '1']];
const normalize = pairs => pairs.map(pair => pair.map(s => s.normalize('NFC')));
const encode = s => encodeURIComponent(s).replace(
/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase()
);
const query = normalize(queryPairs).map(
([key, value]) => `${encode(key)}=${encode(value)}`
).join('&');
const body = JSON.stringify(normalize(bodyPairs));
const payload = `demo-v1\n${query}\n${body}`;
const bytes = Buffer.from(payload, 'utf8');
console.log(JSON.stringify({
formSpace: new URLSearchParams([['q', 'AI agent']]).toString(),
componentSpace: 'q=' + encodeURIComponent('AI agent'),
query,
body,
hex: bytes.toString('hex'),
// Diagnostic digest only. This is NOT an authentication scheme.
sha256: createHash('sha256').update(bytes).digest('hex'),
}));
再保存 parameter_bytes_demo.py:
import hashlib
import json
import sys
import unicodedata
from pathlib import Path
from urllib.parse import parse_qsl, quote, urlencode
def check(condition, label):
if not condition:
raise AssertionError(label)
def compare(label, left, right):
a, b = left.encode('utf-8'), right.encode('utf-8')
check(a != b, label)
first = next((i for i, (x, y) in enumerate(zip(a, b)) if x != y),
min(len(a), len(b)))
print(f'{label}: bytes={len(a)}/{len(b)}, first_diff={first}')
space_a, space_b = 'q=AI+agent', 'q=AI%20agent'
check(parse_qsl(space_a) == parse_qsl(space_b), 'form decoding')
compare('space', space_a, space_b)
pairs = [('tag', 'python'), ('tag', 'js')]
compare('duplicate', urlencode(pairs), urlencode(dict(pairs)))
compare('order', urlencode(pairs), urlencode(list(reversed(pairs))))
obj = {'name': '爬虫', 'page': '1'}
compact = json.dumps(obj, ensure_ascii=False, separators=(',', ':'))
compare('json_escape', json.dumps(obj, separators=(',', ':')), compact)
compare('json_space', json.dumps(obj, ensure_ascii=False), compact)
compare('json_order', compact, json.dumps(
dict(reversed(list(obj.items()))), ensure_ascii=False, separators=(',', ':')))
compare('unicode', 'Caf\u00e9', 'Cafe\u0301')
compare('newline', compact, compact + '\n')
query_pairs = [
('q', 'AI + 爬虫'), ('tag', 'python'), ('tag', 'js'),
('word', 'Cafe\u0301'), ('punct', "!*'()~"),
]
body_pairs = [('name', 'Cafe\u0301'), ('page', '1')]
def normalize(pairs):
return [[unicodedata.normalize('NFC', s) for s in pair] for pair in pairs]
query = '&'.join(quote(k, safe='-._~') + '=' + quote(v, safe='-._~')
for k, v in normalize(query_pairs))
body = json.dumps(normalize(body_pairs), ensure_ascii=False, separators=(',', ':'))
payload = f'demo-v1\n{query}\n{body}'.encode('utf-8')
digest = hashlib.sha256(payload).hexdigest()
if len(sys.argv) != 2:
raise SystemExit('Usage: python3 parameter_bytes_demo.py node_fixture.json')
js = json.loads(Path(sys.argv[1]).read_text(encoding='utf-8'))
check(js['formSpace'] == space_a, 'Node form space')
check(js['componentSpace'] == space_b, 'Node component space')
check(js['query'] == query, 'cross-language query')
check(js['body'] == body, 'cross-language body')
check(js['hex'] == payload.hex(), 'cross-language bytes')
check(js['sha256'] == digest, 'cross-language digest')
print('query=' + query)
print('body=' + body)
print('sha256=' + digest)
print('PASS: 8 byte-difference cases; Node/Python payload identical')
在两个文件所在目录运行:
node parameter_bytes_demo.mjs > parameter_bytes_node_fixture.json
python3 parameter_bytes_demo.py parameter_bytes_node_fixture.json
Node 输出真实序列化结果,Python 先检查 8 类差异,再读取这个结果逐项比对。测试比较了实际字节,摘要是额外核对项。Node 使用的摘要 API 见官方 createHash 文档。
4. 本地运行结果
space: bytes=10/12, first_diff=4
duplicate: bytes=17/6, first_diff=4
order: bytes=17/17, first_diff=4
json_escape: bytes=34/28, first_diff=9
json_space: bytes=31/28, first_diff=8
json_order: bytes=28/28, first_diff=2
unicode: bytes=5/6, first_diff=3
newline: bytes=28/29, first_diff=28
query=q=AI%20%2B%20%E7%88%AC%E8%99%AB&tag=python&tag=js&word=Caf%C3%A9&punct=%21%2A%27%28%29~
body=[["name","Café"],["page","1"]]
sha256=b11b31e5418b928b9c4be02798443cbaf2b93c6be51fa53f8764183a27ba4efa
PASS: 8 byte-difference cases; Node/Python payload identical
first_diff 是从 0 开始的字节位置。如果短输入是长输入的前缀,就显示短输入长度。因此最后一项定位的是额外换行,而不是某个字符替换。
5. 用到实际排障时,保留哪些证据
先固定一组脱敏输入,分别保留:原始有序参数、序列化文本、编码名称、字节长度、首个差异附近的十六进制。按“组装参数 → 序列化 → 编码 → 计算”逐层比对,哪一层开始不同,就在那一层检查约定。
自动排序、反复 URL 解码、去掉所有空白、把重复键装进字典,都会改变输入,不能作为默认修复。AI 可以协助整理差异假设和生成边界样例,最后应由固定用例证明两端行为一致。
本实验仅验证列出的字符串、顺序和编码规则。它不覆盖任意对象的规范化、浮点数、孤立代理项、任意 Unicode 的跨版本兼容,也不证明请求一定能通过服务端业务校验。新增数据类型时,应先补协议规则和跨语言用例,再扩展实现。
本文由 AI 辅助整理与编写,示例已在上述本地环境运行验证,未测试任何真实网站接口。

417

被折叠的 条评论
为什么被折叠?



