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