Files
bare/internal/api/ratelimit_test.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

306 lines
14 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_test
import (
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/xmatic-squad/bare/internal/api"
)
// nickOf — ник для очередного аккаунта теста.
func nickOf(i int) string { return fmt.Sprintf("marta%d", i) }
// fromIP — соединение с этого адреса, без заголовков.
func fromIP(ip string) func(*http.Request) { return withRemote(ip + ":41000") }
// Регистрация — 5 в час на IP (ADR-021).
func TestRegisterRateLimit(t *testing.T) {
e := newEnv(t)
one := fromIP("203.0.113.7")
for i := 0; i < 5; i++ {
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one), http.StatusCreated, "")
}
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one)
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// Токен набегает раз в двенадцать минут; ведро пусто, значит ждать
// почти все 720 секунд.
if got := retryAfterOf(t, rec); got < 700 || got > 720 {
t.Errorf("Retry-After: получено %d, ожидалось около 720", got)
}
// Отказ ничего не завёл.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "kolya", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
// Другой адрес — своё ведро.
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), fromIP("203.0.113.8")),
http.StatusCreated, "")
}
// Неудачная регистрация тратит попытку так же, как удачная: иначе занятые
// ники перебирались бы без счёта.
func TestRegisterRateLimitCountsFailures(t *testing.T) {
e := invited(t, "секрет")
one := fromIP("203.0.113.7")
for i := 0; i < 5; i++ {
body := account(nickOf(i))
body["invite"] = "не секрет"
expect(t, e.do(http.MethodPost, "/api/register", body, one), http.StatusForbidden, "invalid_invite")
}
right := account("marta")
right["invite"] = "секрет"
expect(t, e.do(http.MethodPost, "/api/register", right, one), http.StatusTooManyRequests, "rate_limited")
// Форма разбирается раньше лимита и попытки не тратит (ADR-043).
fresh := fromIP("203.0.113.9")
for i := 0; i < 20; i++ {
body := account("МАРТА")
body["invite"] = "секрет"
expect(t, e.do(http.MethodPost, "/api/register", body, fresh), http.StatusBadRequest, "invalid_nick")
}
expect(t, e.do(http.MethodPost, "/api/register", right, fresh), http.StatusCreated, "")
}
// Вход — 10 за 10 минут на пару IP+ник (ADR-021).
func TestLoginRateLimit(t *testing.T) {
e := newEnv(t)
e.signUp("marta")
e.signUp("petya")
one := fromIP("203.0.113.7")
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
for i := 0; i < 10; i++ {
expect(t, e.do(http.MethodPost, "/api/login", wrong, one), http.StatusUnauthorized, "invalid_credentials")
}
rec := e.do(http.MethodPost, "/api/login", wrong, one)
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
if got := retryAfterOf(t, rec); got < 55 || got > 60 {
t.Errorf("Retry-After: получено %d, ожидалось около 60", got)
}
// Верный пароль с того же адреса ждёт вместе с неверными: ведро
// на паре, а не на исходе попытки.
right := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}
expect(t, e.do(http.MethodPost, "/api/login", right, one), http.StatusTooManyRequests, "rate_limited")
// Другой ник с того же адреса — своё ведро.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 9)}, one),
http.StatusUnauthorized, "invalid_credentials")
// Тот же ник с другого адреса — тоже своё.
expect(t, e.do(http.MethodPost, "/api/login", right, fromIP("203.0.113.8")), http.StatusOK, "")
}
// Остальные изменяющие запросы — 60 в минуту на пользователя (ADR-021).
func TestWritesRateLimit(t *testing.T) {
e := newEnv(t)
marta := e.signUp("marta")
id := deviceOf(1)
for i := 0; i < 60; i++ {
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
if rec.Code >= http.StatusMultipleChoices {
t.Fatalf("запрос %d из шестидесяти отклонён: %d (%s)", i+1, rec.Code, rec.Body.String())
}
}
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// Шестьдесят в минуту — токен раз в секунду.
if got := retryAfterOf(t, rec); got != 1 {
t.Errorf("Retry-After: получено %d, ожидалось 1", got)
}
// Ведро общее на все изменяющие маршруты пользователя.
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
http.StatusTooManyRequests, "rate_limited")
expect(t, e.do(http.MethodPost, "/api/logout", nil, with(marta)),
http.StatusTooManyRequests, "rate_limited")
// Чтения лимитом не считаются: ADR-021 ограничивает изменяющие.
expect(t, e.do(http.MethodGet, "/api/devices", nil, with(marta)), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/api/me", nil, with(marta)), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/api/rooms", nil, with(marta)), http.StatusOK, "")
// Сообщения считаются своим правилом и своим ведром.
petya := e.signUp("petya")
e.addDevice(petya, deviceOf(2))
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
with(marta), withDevice(id)), http.StatusAccepted, "")
// Другой пользователь чужим лимитом не задет. Строка контакта уже есть:
// её завело сообщение, поэтому 200, а не 201 (ADR-019).
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(petya)),
http.StatusOK, "")
}
// Лимит сообщений считается отдельно от общего: тридцать в минуту
// не отнимают шестьдесят у остальных запросов (ADR-021).
func TestMessagesOutsideWritesLimit(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
e.join("petya", 2)
for i := 0; i < 10; i++ {
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), byte(i)), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
}
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"),
with(marta), withDevice(m1)), http.StatusTooManyRequests, "rate_limited")
// Пакет сообщений кончился, изменяющие запросы работают.
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(marta), withDevice(m1)),
http.StatusNoContent, "")
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
http.StatusOK, "")
}
// X-Real-IP ставит nginx с той же машины: за ним у каждого адреса своё
// ведро (ADR-055).
func TestRealIPFromLoopback(t *testing.T) {
e := newEnv(t)
nginx := fromIP("127.0.0.1")
for i := 0; i < 5; i++ {
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), nginx, withRealIP("198.51.100.7")),
http.StatusCreated, "")
}
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.7")),
http.StatusTooManyRequests, "rate_limited")
// Соседний адрес за тем же nginx ждать не должен.
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.8")),
http.StatusCreated, "")
}
// Заголовок из сети не читается: иначе лимит на IP снимался бы новой
// строкой в заголовке, то есть не существовал бы вовсе (ADR-055).
func TestRealIPFromNetworkIgnored(t *testing.T) {
e := newEnv(t)
one := fromIP("203.0.113.7")
for i := 0; i < 5; i++ {
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one,
withRealIP(fmt.Sprintf("198.51.100.%d", i))), http.StatusCreated, "")
}
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one, withRealIP("198.51.100.200"))
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// И на входе тоже: ведро на паре адрес соединения + ник.
e2 := newEnv(t)
e2.signUp("marta")
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
for i := 0; i < 10; i++ {
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP(fmt.Sprintf("198.51.100.%d", i))),
http.StatusUnauthorized, "invalid_credentials")
}
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP("198.51.100.200")),
http.StatusTooManyRequests, "rate_limited")
}
// Предел тела — 32 КиБ на всех маршрутах, ответ один: 413 too_large
// (ADR-026). Отказ приходит раньше сессии, устройства и разбора тела.
func TestTooLargeEverywhere(t *testing.T) {
e := newEnv(t)
marta, device := e.join("marta", 1)
big := strings.Repeat("a", api.MaxBody+1)
routes := []struct{ method, target string }{
{http.MethodPost, "/api/register"},
{http.MethodPost, "/api/login"},
{http.MethodPost, "/api/logout"},
{http.MethodPost, "/api/password"},
{http.MethodDelete, "/api/me"},
{http.MethodPost, "/api/devices"},
{http.MethodDelete, "/api/devices/" + device},
{http.MethodPut, "/api/devices/" + device + "/push"},
{http.MethodDelete, "/api/devices/" + device + "/push"},
{http.MethodPost, "/api/contacts"},
{http.MethodDelete, "/api/contacts/petya"},
{http.MethodPost, "/api/rooms"},
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/members"},
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/leave"},
{http.MethodDelete, "/api/rooms/" + roomIDOf(1)},
{http.MethodPost, "/api/messages"},
{http.MethodPost, "/api/ack"},
{http.MethodGet, "/api/me"},
{http.MethodGet, "/api/events?device=" + device},
}
for _, route := range routes {
t.Run(route.method+" "+route.target, func(t *testing.T) {
// С сессией и своим устройством — отказ всё равно по телу.
expect(t, e.do(route.method, route.target, big, with(marta), withDevice(device)),
http.StatusRequestEntityTooLarge, "too_large")
// И без сессии тоже: тело проверяется раньше прав (ADR-043).
expect(t, e.do(route.method, route.target, big),
http.StatusRequestEntityTooLarge, "too_large")
})
}
}
// Тело без заявленной длины обрывается при чтении — тем же кодом и там,
// где раньше отвечало устройство (ADR-043).
func TestTooLargeUnannounced(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
_, foreign := e.join("petya", 2)
// Валидный json, чтобы разбор дошёл до предела чтения, а не споткнулся
// о первый же байт.
body := `{"id":"` + strings.Repeat("a", api.MaxBody) + `"}`
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms", "/api/devices", "/api/contacts"} {
rec := e.do(http.MethodPost, target, nil, with(marta), withDevice(foreign), func(r *http.Request) {
r.Body = io.NopCloser(strings.NewReader(body))
r.ContentLength = -1
})
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
}
}
// Форма запроса разбирается раньше прав: кривое тело с чужим устройством
// отвечает про тело, а не про устройство (ADR-043).
func TestFormBeforeDevice(t *testing.T) {
e := newEnv(t)
_, martaDevice := e.join("marta", 1)
petya, _ := e.join("petya", 2)
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms"} {
expect(t, e.do(http.MethodPost, target, "{", with(petya), withDevice(martaDevice)),
http.StatusBadRequest, "bad_json")
// Заголовка нет вовсе — то же самое.
expect(t, e.do(http.MethodPost, target, "{", with(petya)),
http.StatusBadRequest, "bad_json")
}
// Кривое поле тела — invalid с этим полем, хотя устройство чужое.
rec := e.do(http.MethodPost, "/api/messages", message("не ulid", "marta"), with(petya), withDevice(martaDevice))
expect(t, rec, http.StatusBadRequest, "invalid")
if got := field(t, rec); got != "id" {
t.Errorf("field: получено %q, ожидалось \"id\"", got)
}
// Часы — свойство самого запроса, а не право: clock_skew тоже раньше
// (docs/protocol.md, «Сообщения»).
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis()-6*60*1000, 3), "marta"),
with(petya), withDevice(martaDevice)), http.StatusBadRequest, "clock_skew")
ids := make([]string, 501)
for i := range ids {
ids[i] = ulid(nowMillis(), byte(i))
}
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(petya), withDevice(martaDevice)),
http.StatusBadRequest, "invalid")
room := map[string]any{
"id": "не комната",
"name": "общая",
"keyId": keyID(40),
"keys": keysFor([]string{"petya"}, 40),
}
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)),
http.StatusBadRequest, "invalid")
// Тело по форме — тогда отказ по устройству.
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"),
with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device")
}