Files
bare/docs/decisions/055-limit-keys-and-bounds.md
mayatnikovandClaude Opus 5 db45978b16 Этап 6: закалка — лимиты ADR-021, аудит модели угроз, сверка документов
Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин
на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After;
X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок
отменял бы лимит на IP; карты вёдер ограничены поколениями.

Аудит нашёл то, что пропустили пять раундов ревью:
ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md.
Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare.
ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная
сессия продолжала получать сообщения.
ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший
офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа —
вопреки обещанию storage.md о повторной попытке после получения keyId.
ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой
комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих
запросах, включая выход из комнаты: 116 отказов за прогон стало нулём.
ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version,
403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода,
kdf как оракул после повышения цели KDF.

Модель угроз пополнена тем, что действительно видит оператор: push-подписки
лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это
произвольное уведомление на экране блокировки.

README приведён к v1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 06:22:52 +03:00

7.6 KiB
Raw Permalink Blame History

ADR-055: Ключи лимитов, границы карт и доверие к X-Real-IP

Уточняет ADR-021: четыре правила названы там, всё остальное про них — здесь.

Уточнён ADR-063: POST /api/ack приходит пачками не только при подключении — клиент копит подтверждения и шлёт их не чаще раза в две секунды.

Контекст

ADR-021 задаёт лимиты одним списком: регистрация — 5 в час на IP, вход — 10 за 10 минут на пару IP+ник, сообщения — 30 в минуту на пользователя пакетом 10, остальные изменяющие запросы — 60 в минуту на пользователя. Этап 6 доводит список до кода, и пять вещей списком не решены.

Пакет. Он назван только у сообщений. У остальных правил его нет, а token bucket без него не собрать.

«Остальные изменяющие». Какие именно и одним ли ведром — не сказано. POST /api/ack изменяет очередь, но приходит пачками после каждого подключения; GET не изменяет ничего.

Место в порядке проверок. У сообщений оно записано (docs/protocol.md, «Сообщения»): лимит последний, после формы и прав. Для общего лимита такого места нет: чтобы спросить ведро, нужен только ник сессии, а разбор тела — уже та работа, ради отказа от которой лимит и заводится.

Размер карт. Ведро заводится на каждый новый ключ, а ключ — чужой адрес: их бывает сколько угодно. Выбрасывать полные вёдра, как делал этап 2, под потоком новых ключей бесполезно — полных не бывает, каждое только что потратило токен. Миллион адресов давал миллион вёдер и рост памяти без предела.

X-Real-IP. ADR-021 говорит «только если соединение с 127.0.0.1». Соединение с ::1 приходит с той же машины и заслуживает того же доверия, а по букве оно его не получает: тогда все клиенты за таким nginx складываются в одно ведро, и лимит на IP превращается в лимит на сервер.

Рядом — расхождение в docs/deploy.md: «Логи» разрешают писать ник «для ошибок аутентификации по лимитам». Такой строки в коде нет и не заводится: ник — данные пользователя, а отказ и так видно по статусу.

Решение

  • Пакет равен лимиту, где ADR-021 его не назвал: 5 в час — пакет 5, 10 за 10 минут — 10, 60 в минуту — 60. За окно набегает ровно лимит, и потратить его можно разом. Отдельный пакет остаётся у сообщений: 30 в минуту, пакет 10.
  • «Остальные изменяющие» — все непубличные маршруты, кроме GET, одним ведром на пользователя, включая POST /api/ack. Чтения не ограничиваются: ADR-021 ограничивает изменяющие, и большего v1 не вводит. POST /api/messages в это ведро не входит — у него своё правило.
  • Общий лимит стоит на маршруте, сразу за проверкой сессии, и отвечает раньше разбора тела. ADR-043 это не нарушает: 429 говорит не о правах и не о существовании сущностей, а о частоте; 401 unauthenticated стоит там же и раньше.
  • У регистрации, входа и сообщений лимит стоит в обработчике: после проверки формы и до работы. У сообщений это записанное место в порядке проверок. У входа — раньше обращения к хранилищу и argon2: перебор не должен заказывать серверу работу. У регистрации — раньше проверки инвайт-кода, иначе код подбирается запросами без счёта.
  • Форма регистрации проверяется раньше инвайт-кода (ADR-043). Занятость ника по-прежнему за ним: 409 nick_taken живёт после проверки кода, и без кода ники не перебрать.
  • Карты вёдер ограничены сменой поколения. Карт две: нынешняя и прежняя. Как только нынешняя дорастает до 4096 ключей, она становится прежней, а прежняя выбрасывается целиком. Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену переживает. Обе карты вместе — не больше 8192 вёдер на правило.
  • X-Real-IP читается с любого loopback-адреса127.0.0.0/8 и ::1. Соединение не с loopback — заголовок не читается вовсе, ключом становится адрес соединения.
  • Ник в журнал не пишется никогда, включая отказы по лимитам; строка про это убрана из docs/deploy.md.

Следствия

  • Лимит на IP держится ровно до тех пор, пока nginx — единственный, кто ходит на порт. Прямой доступ к 8411 снаружи снял бы его целиком, поэтому порт слушается на 127.0.0.1 (ADR-022).
  • Миллион разных адресов стоит около мегабайта на правило, а не гигабайта. Цена — поток чужих ключей протирает ведро того, кого лимит держал: забытое ведро равно новому. ADR-021 уже принял, что рестарт обнуляет лимиты; это то же самое, только чаще.
  • Промахнувшийся инвайт-кодом пять раз ждёт час. Числа ADR-021 не меняются: барьер от ботов дороже удобства опечатки.
  • Клиент отличает 429 от прочих отказов по коду rate_limited и показывает «слишком часто, попробуйте позже» (ADR-028). Новых текстов интерфейса решение не заводит.