Этап 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
+305
View File
@@ -0,0 +1,305 @@
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")
}