Этап 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:
+136
-32
@@ -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("")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user