Шлюз увидел «user», сервис увидел «admin», а JSON был один и тот же

Принято считать, что JSON — самая скучная часть системы. Формат простой, парсер есть в любом языке, сломаться там нечему. Скорее спорят, как назвать поле, чем о том, что оно значит.
На деле JSON описывает синтаксис и почти ничего не говорит о смысле.
Что делать с двумя одинаковыми ключами в одном объекте, сколько знаков держит число, можно ли записать NaN — на всё это стандарт отвечает «на усмотрение реализации». Каждая библиотека усмотрела по‑своему.
Пока JSON пишет и читает один и тот же сервис, этого не видно. Как только между ними оказывается второй язык или второй парсер, один и тот же текст начинает означать разные вещи.
В статье рассмотрим пять таких расхождений, и все они растут из одного пробела в стандарте.
Начнём с самого неприятного, после которого страдают права доступа.
Два ключа role
Возьмём объект, в котором один ключ встречается дважды:
{"user": "u42", "role": "user", "role": "admin"}Это валидный JSON. RFC 8259, текущая версия стандарта от декабря 2 017 года, говорит, что имена внутри объекта SHOULD be unique, то есть «желательно».
И тут же предупреждает: если имена не уникальны, поведение получателя непредсказуемо. Одни берут последнюю пару, другие падают с ошибкой, третьи отдают обе.
Я прогнал этот объект:
{"role": "user", "role": "admin"}
Python json admin
orjson 3.13 admin
pysimdjson 7.0 user
Node.js 22, JSON.parse admin
Go, encoding/json admin
Go 1.27, encoding/json/v2 ошибка: duplicate object member name "role"
Jackson 2.18 admin
Jackson + STRICT_DUPLICATE ошибка: Duplicate field 'role'
PostgreSQL json хранит оба ключа, ->> отдаёт admin
PostgreSQL jsonb admin
jq 1.7 adminБольшинство берёт последнее значение, и легко решить, что так делают все.
Но pysimdjson, обёртка над simdjson, которую ставят ради скорости, отдаёт первое.
Go с версии 1.27, вышедшей в августе 2 026 года, стоит на новом движке
encoding/json/v2. Через новый API он такой объект просто не принимает.Старый
encoding/jsonпо‑прежнему берёт последнее значение, потому что его поведение сохранили ради совместимости.PostgreSQL тоже интересен. Тип
jsonхранит текст как есть, с обоими ключами, иjson_eachвернёт две строки. Типjsonbпри разборе оставляет последнюю пару и про первую забывает навсегда.
Где из этого получается проблема
Сам по себе разнобой ещё не беда. Беда начинается, когда одно тело читают два разных парсера и принимают по нему разные решения.
Базовая схема — шлюз или middleware проверяет запрос, а сервис за ним его выполняет:
import json, simdjson
body = b'{"user": "u42", "role": "user", "role": "admin"}'
def gateway(raw): # проверка на входе, быстрый парсер
doc = simdjson.Parser().parse(raw)
if doc["role"] == "admin":
raise PermissionError("роль admin выдаётся только через SSO")
return raw # дальше уходит исходное тело
def handler(raw): # сервис за шлюзом
return json.loads(raw)["role"]
print("шлюз пропустил, сервис видит роль:", handler(gateway(body)))
шлюз пропустил, сервис видит роль: adminНи в одной из двух библиотек нет ошибки. Каждая сделала то, что разрешает стандарт. Шлюз увидел user и пропустил запрос.
Сервис увидел admin и выдал права. Тело никто не подделывал, его просто прочитали дважды.
Откуда такие тела берутся, если клиентам никто не разрешал повторять ключи? Чаще всего их собирает не атакующий, а свой же код.
Шаблон, который склеивает JSON строками и дописывает служебное поле в конец, middleware, которое «добавляет» роль к телу, не разбирая его, логгер, который вклеивает контекст в уже готовую строку, — все они рано или поздно получают на вход объект, где нужный ключ уже был, и тихо делают из него объект с двумя одинаковыми ключами, который дальше каждый компонент прочитает по‑своему.
В реальной системе парсеры совпадают ещё реже, чем в этом примере.
Шлюз на Go, проверка схемы на Node.js, бизнес‑логика на Python, аудит пишется в jsonb. Каждый компонент проверен по отдельности, а вместе они смотрят на разные объекты.
Правило из этого простое. Решение и действие должны опираться на один и тот же разбор. Шлюз, который проверил тело, отдаёт дальше не исходные байты, а то, что сам разобрал и заново сериализовал.
Тогда второго ключа просто не существует, либо шлюз отклоняет объект с повтором ключей вообще, и это ещё лучше.
ROLE, role и регистр, которого нет в стандарте
У Go есть своя версия той же истории, и она даже без повторяющихся ключей. Старый encoding/json при разборе в структуру сопоставляет имена полей без учёта регистра.
Документация формулирует все это так, что точное совпадение предпочтительнее, но регистронезависимое тоже принимается.
Посмотрим, что это значит:
type Req struct {
User string `json:"user"`
Role string `json:"role"`
}
b := []byte(`{"user": "u42", "role": "user", "ROLE": "admin"}`)Python json {'user': 'u42', 'role': 'user', 'ROLE': 'admin'}
Go 1.27, encoding/json role="admin"
Go 1.27, encoding/json/v2 role="user"Для Python, Node.js и любого валидатора JSON Schema role и ROLE — два разных ключа, и роль тут однозначно user.
Старый Go разбирает ключи по порядку: сначала точное role кладёт в поле user, потом ROLE подходит без учёта регистра и перезаписывает поле на admin. «Предпочитает точное совпадение» на деле значит «точное совпадение тоже подойдёт».
В encoding/json/v2 имена по умолчанию сравниваются с учётом регистра, и поле остаётся user. Регистронезависимость там включается отдельно, тегом case:ignore на конкретном поле.
Если ваш шлюз написан не на Go, а сервис за ним на Go со старым API, этот вариант подмены ролей работает даже против строгой проверки дубликатов: формально повторяющихся ключей в объекте нет.
9 007 199 254 740 993
Второе место, где парсеры расходятся, — числа.
Стандарт разрешает реализациям ограничивать диапазон и точность и отдельно говорит, что совместимость гарантирована только для целых в диапазоне от −(2^53 − 1) до 2^53 − 1. Всё, что больше, — на ваш страх и риск.
Возьмём 64-битный идентификатор чуть больше границы:
{"id": 9007199254740993}
Python json 9007199254740993
Jackson 2.18 (Long) 9007199254740993
PostgreSQL jsonb 9007199254740993
Go, в struct с int64 9007199254740993
Go, в map[string]any 9007199254740992
Node.js 22, JSON.parse 9007199254740992
jq 1.7, вывод как есть 9007199254740993
jq 1.7, .id + 0 9007199254740992Ошибок нет нигде. JavaScript и Go при разборе в any молча превращают число в float64, и идентификатор становится соседним. Только вот соседний идентификатор вполне может существовать. Запрос найдёт чужую запись и ничего не заподозрит.
jq тут показывает, как это выглядит изнутри. С версии 1.7, вышедшей в сентябре 2 023 года, он сохраняет литерал как есть при выводе, но любая арифметика уже идёт в double. Достаточно прибавить ноль.
У orjson своя граница: числа больше беззнакового 64-битного он молча превращает во float, 18446744073709551616 становится 1.8446744073709552e+19.
Исправляется это двумя способами.
Первый подсказывает I‑JSON, профиль JSON из RFC 7 493 от марта 2 015 года: числа, которые не помещаются в
double, передавайте строкой. Поэтому идентификаторы в публичных API так часто приходят строкой.Второй способ — научить читающую сторону. В JavaScript для этого появился доступ к исходному тексту числа при разборе, а
JSON.rawJSONсо статусом Baseline с марта 2 025 года работает во всех основных браузерах:
const t = `{"id": 9007199254740993, "amount": 12.30}`;
const d = JSON.parse(t, (k, v, ctx) => k === "id" ? BigInt(ctx.source) : v);
// { id: 9007199254740993n, amount: 12.3 }
JSON.stringify({ id: JSON.rawJSON(d.id.toString()) });
// {"id":9007199254740993}Третий аргумент ревайвера содержит исходный текст значения, до того как его превратили в number.
В Node.js 22 это уже работает. В Go тоже решается декодированием в типизированную структуру или Decoder.UseNumber(), а не в map[string]any.
NaN, которого нет в JSON
Третье место — числа, которых в JSON нет вообще. Стандарт запрещает NaN и Infinity явно. Только одна из самых популярных библиотек их пишет по умолчанию:
сериализуем {"x": NaN}
Python json.dumps {"x": NaN} невалидный JSON
orjson 3.13 {"x":null}
Node.js JSON.stringify {"x":null}
Jackson 2.18 {"x":"NaN"} строка
Go encoding/json ошибка: unsupported value: NaNУ json.dumps в Python по умолчанию стоит allow_nan=True, и он без единого предупреждения выдаёт текст, который JSON не считается. Node.js на нём падает с Unexpected token 'N', PostgreSQL отвечает invalid input syntax for type json. Сам Python такой текст спокойно читает обратно, так что тесты, где сервис общается сам с собой, проходят.
Остальные ведут себя не лучше, просто иначе. null вместо NaN и строка "NaN" вместо числа — это потеря данных, которую получатель не отличит от настоящего null или настоящей строки. Где‑то в отчётах начинают появляться пустые ячейки, и никто не понимает, откуда.
Минимум, который стоит сделать в Python, — allow_nan=False. Тогда сериализация упадёт сразу с Out of range float values are not JSON compliant, и вы узнаете о проблеме в месте, где её можно исправить.
1.0 и 1
Python и Jackson пишут 1.0 как 1.0. Node.js пишет 1. jsonb в PostgreSQL сохраняет 1.0 и возвращает его же. Для всех них это одно и то же число, но байты разные.
Пока JSON только парсят, никто этого не замечает. Проблемы начинаются, когда от сериализованного тела считают хэш, подпись или ключ кэша.
Сервис на Python подписал {"amount": 10.0}, промежуточный сервис на Node.js разобрал тело и собрал заново, получилось {"amount":10}, и подпись больше не сходится. Никто ничего не менял, данные те же.
Для таких случаев есть RFC 8785, схема канонизации JSON. Она задаёт порядок ключей, формат чисел и экранирование, чтобы одно значение всегда давало одни и те же байты.
Если хэш или подпись считаются от JSON, считать их надо от канонической формы, а не от того, что случайно выдал сериализатор.
Разбираем все на границе
Общая причина у всех пяти историй одна. RFC 8259 оставил дыры, и каждая библиотека закрыла их по‑своему. Поменять стандарт мы не можем. Можем решить, какой его частью пользоваться, и проверять это на границе системы.
Для этого и существует I‑JSON. Профиль запрещает повторяющиеся ключи, просит держать числа в пределах double, а то, что не влезает, передавать строкой. Достаточно принять его как контракт API и проверять входящие данные одним строгим парсером, а не надеяться, что все компоненты прочитают одинаково.
Проверка дубликатов в Python пишется в несколько строк:
import json
def no_dups(pairs):
keys = [k for k, _ in pairs]
dup = {k for k in keys if keys.count(k) > 1}
if dup:
raise ValueError(f"duplicate keys: {sorted(dup)}")
return dict(pairs)
json.loads('{"role":"user","role":"admin"}', object_pairs_hook=no_dups)
# ValueError: duplicate keys: ['role']В Jackson тоже включается флагом JsonParser.Feature.STRICT_DUPLICATE_DETECTION. В Go 1.27 через encoding/json/v2 дубликаты отклоняются по умолчанию, причём там это решили сделать нормой, а не опцией.
В Node.js стандартный JSON.parse дубликатов не замечает в принципе, и для строгой проверки понадобится сторонний парсер.
Не нужно гонять строгий режим через все сервисы. Достаточно одного места на входе, которое разбирает тело строго, отклоняет то, что не проходит, и передаёт дальше то, что само сериализовало. Внутри системы тогда ходит уже нормализованный JSON, и расхождение парсеров больше ни на что не влияет.
Самое дешёвое, с чего можно начать, — проверить, сколько разных парсеров читает одно и то же тело по дороге от клиента до базы.
Если больше одного и решения принимаются в разных местах, строгий разбор на входе окупится с первого же странного запроса. А allow_nan=False в Python стоит включить просто так, это одна строка.
Идентификаторы больше 2^53 лучше перевести в строки до того, как первый клиент на JavaScript начнёт путать соседние записи, потому что такую ошибку в логах не видно вообще.

Один и тот же JSON может пройти проверку на шлюзе, но привести к ошибке в сервисе. Чтобы такие расхождения не становились уязвимостями в продакшене, важно заранее проверять пограничные случаи и поведение API.
Как находить подобные ошибки до релиза, разберём на бесплатных вебинарах:
26 октября, 20:00. «Тестирование FastAPI: проверяем API с помощью pytest». Записаться.
5 ноября, 20:00. «Тесты в Go без боли: ловим баги раньше пользователей». Записаться.
Больше практических занятий для разработчиков — в дайджесте открытых уроков.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.