Этап 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
This commit is contained in:
2026-08-23 06:22:52 +03:00
co-authored by Claude Opus 5
parent 0c878477d2
commit db45978b16
45 changed files with 1522 additions and 282 deletions
+136 -32
View File
@@ -2,27 +2,65 @@ package api
import (
"math"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
"github.com/xmatic-squad/bare/internal/auth"
)
// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10.
// Остальные лимиты — этап 6.
const (
messagesPerMinute = 30
messagesBurst = 10
// Лимиты ADR-021, все четыре правила. Token bucket в памяти сервера:
// рестарт их обнуляет — для маленького сервера это принято.
//
// Пакет отдельным числом задан только у сообщений. У остальных правил он
// равен самому лимиту: «5 в час» означает, что за час набегает пять
// попыток и потратить их можно разом (ADR-055).
var (
// registerRule — регистрация: 5 в час на IP.
registerRule = rule{count: 5, window: time.Hour, burst: 5}
// loginRule — вход: 10 за 10 минут на пару IP+ник.
loginRule = rule{count: 10, window: 10 * time.Minute, burst: 10}
// messagesRule — сообщения: 30 в минуту на пользователя, пакет 10.
messagesRule = rule{count: 30, window: time.Minute, burst: 10}
// writesRule — остальные изменяющие запросы: 60 в минуту
// на пользователя.
writesRule = rule{count: 60, window: time.Minute, burst: 60}
)
// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра.
const sweepAt = 1024
// rule — правило лимита: count запросов за window, пакетом не больше burst.
type rule struct {
count int
window time.Duration
burst int
}
// buckets — token bucket в памяти сервера, по ведру на ключ (ник).
// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021).
// generation — сколько ключей карта лимита держит до смены поколения.
//
// Ведро заводится на каждый новый ключ, а ключ — это чужой адрес или чужой
// ник: их бывает сколько угодно. Выбрасывать полные вёдра мало: под потоком
// новых ключей полных не бывает вовсе — каждое только что потратило токен.
// Поэтому карты две, нынешняя и прежняя. Как только нынешняя дорастает до
// generation, она становится прежней, а прежняя выбрасывается целиком.
// Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену
// переживает; забывается только то, к чему не обращались целое поколение,
// а забытое ведро — то же самое, что новое.
//
// Отсюда предел: обе карты вместе держат не больше 2×generation вёдер,
// то есть около мегабайта на правило. Миллион разных адресов памяти
// не съедает — он протачивает поколения насквозь.
const generation = 4096
// buckets — token bucket в памяти сервера, по ведру на ключ.
type buckets struct {
mu sync.Mutex
rate float64 // токенов в секунду
burst float64
seen map[string]*bucket
cur map[string]*bucket // нынешнее поколение
old map[string]*bucket // прежнее, пока к его ключам ещё обращаются
}
type bucket struct {
@@ -30,11 +68,11 @@ type bucket struct {
at time.Time
}
func newBuckets(perMinute, burst int) *buckets {
func newBuckets(r rule) *buckets {
return &buckets{
rate: float64(perMinute) / 60,
burst: float64(burst),
seen: make(map[string]*bucket),
rate: float64(r.count) / r.window.Seconds(),
burst: float64(r.burst),
cur: make(map[string]*bucket),
}
}
@@ -44,14 +82,7 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
b.mu.Lock()
defer b.mu.Unlock()
e, ok := b.seen[key]
if !ok {
if len(b.seen) >= sweepAt {
b.sweep(now)
}
e = &bucket{tokens: b.burst, at: now}
b.seen[key] = e
}
e := b.bucket(key, now)
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
e.at = now
if e.tokens < 1 {
@@ -61,6 +92,27 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
return 0, true
}
// bucket находит ведро ключа или заводит новое. Смена поколения идёт
// до поиска: так в нынешней карте никогда не больше generation ключей,
// а в обеих вместе — не больше двух таких карт.
func (b *buckets) bucket(key string, now time.Time) *bucket {
if len(b.cur) >= generation {
b.old = b.cur
b.cur = make(map[string]*bucket, generation)
}
if e, ok := b.cur[key]; ok {
return e
}
if e, ok := b.old[key]; ok {
delete(b.old, key)
b.cur[key] = e
return e
}
e := &bucket{tokens: b.burst, at: now}
b.cur[key] = e
return e
}
// refill — сколько токенов набежало. Время назад не идёт: часы могли
// прыгнуть, но долг за это выставлять некому.
func (b *buckets) refill(since, now time.Time) float64 {
@@ -71,16 +123,6 @@ func (b *buckets) refill(since, now time.Time) float64 {
return d.Seconds() * b.rate
}
// sweep выкидывает полные вёдра: они уже ничего не помнят. Иначе карта
// росла бы на каждый новый ник и не уменьшалась никогда.
func (b *buckets) sweep(now time.Time) {
for key, e := range b.seen {
if e.tokens+b.refill(e.at, now) >= b.burst {
delete(b.seen, key)
}
}
}
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
// ожидание после отказа сбивало бы клиента с толку.
func retryAfter(wait time.Duration) int {
@@ -89,3 +131,65 @@ func retryAfter(wait time.Duration) int {
}
return int(math.Ceil(wait.Seconds()))
}
// rateLimited — 429 с Retry-After в целых секундах (ADR-021).
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
}
// limitWrites — общий лимит изменяющих запросов: 60 в минуту
// на пользователя (ADR-021). Стоит на маршруте, а не в обработчике,
// поэтому отвечает раньше разбора тела: смысл лимита в том, чтобы сервер
// не брался за работу, а разбор тела — уже работа. Форму это не обгоняет
// в смысле ADR-043: 429 говорит не о правах и не о существовании
// сущностей, а о частоте.
//
// Сообщения сюда не входят: у них своё правило, своё ведро и своё место
// в порядке проверок (docs/protocol.md, «Сообщения»).
func (s *server) limitWrites(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
if wait, ok := s.writes.take(sess.Nick, time.Now()); !ok {
s.rateLimited(w, wait)
return
}
next.ServeHTTP(w, r)
})
}
// clientIP — ключ лимитов, привязанных к адресу.
//
// X-Real-IP ставит nginx на той же машине (ADR-022), и верить заголовку
// можно только тогда, когда соединение пришло оттуда же. Иначе его
// подставит кто угодно: новая строка в заголовке — новое ведро, и лимита
// на IP не существует вовсе. Соединение не с loopback — заголовок
// не читается, ключом становится адрес соединения.
func clientIP(r *http.Request) string {
remote := connIP(r.RemoteAddr)
if !remote.IsValid() {
// Адрес соединения не разобрать. Одно общее ведро на всех —
// лучше, чем ни одного.
return r.RemoteAddr
}
if remote.IsLoopback() {
if ip, err := netip.ParseAddr(strings.TrimSpace(r.Header.Get("X-Real-IP"))); err == nil {
return ip.Unmap().WithZone("").String()
}
}
return remote.String()
}
// connIP — адрес, с которого пришло соединение. Невалидный Addr означает,
// что RemoteAddr не разобрать.
func connIP(remote string) netip.Addr {
host, _, err := net.SplitHostPort(remote)
if err != nil {
host = remote
}
ip, err := netip.ParseAddr(host)
if err != nil {
return netip.Addr{}
}
return ip.Unmap().WithZone("")
}