Привет, Хабр!

Принято считать, что 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 без боли: ловим баги раньше пользователей». Записаться.

Больше практических занятий для разработчиков — в дайджесте открытых уроков.

Комментарии (1)


  1. redfox0
    21.10.2026 07:09

    Первая проблема решается обязательной схемой и валидацией перед парсингом.