Files
bare/internal/api/limit.go
T
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

196 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"math"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
"github.com/xmatic-squad/bare/internal/auth"
)
// Лимиты 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}
)
// rule — правило лимита: count запросов за window, пакетом не больше burst.
type rule struct {
count int
window time.Duration
burst int
}
// generation — сколько ключей карта лимита держит до смены поколения.
//
// Ведро заводится на каждый новый ключ, а ключ — это чужой адрес или чужой
// ник: их бывает сколько угодно. Выбрасывать полные вёдра мало: под потоком
// новых ключей полных не бывает вовсе — каждое только что потратило токен.
// Поэтому карты две, нынешняя и прежняя. Как только нынешняя дорастает до
// generation, она становится прежней, а прежняя выбрасывается целиком.
// Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену
// переживает; забывается только то, к чему не обращались целое поколение,
// а забытое ведро — то же самое, что новое.
//
// Отсюда предел: обе карты вместе держат не больше 2×generation вёдер,
// то есть около мегабайта на правило. Миллион разных адресов памяти
// не съедает — он протачивает поколения насквозь.
const generation = 4096
// buckets — token bucket в памяти сервера, по ведру на ключ.
type buckets struct {
mu sync.Mutex
rate float64 // токенов в секунду
burst float64
cur map[string]*bucket // нынешнее поколение
old map[string]*bucket // прежнее, пока к его ключам ещё обращаются
}
type bucket struct {
tokens float64
at time.Time
}
func newBuckets(r rule) *buckets {
return &buckets{
rate: float64(r.count) / r.window.Seconds(),
burst: float64(r.burst),
cur: make(map[string]*bucket),
}
}
// take забирает токен. Второе значение — можно ли; если нет, первое —
// сколько ждать до следующего токена.
func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
b.mu.Lock()
defer b.mu.Unlock()
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 {
return time.Duration((1 - e.tokens) / b.rate * float64(time.Second)), false
}
e.tokens--
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 {
d := now.Sub(since)
if d <= 0 {
return 0
}
return d.Seconds() * b.rate
}
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
// ожидание после отказа сбивало бы клиента с толку.
func retryAfter(wait time.Duration) int {
if wait < time.Second {
return 1
}
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("")
}