feat(security): проверка Origin, строгий CSP и лимиты загрузок

- middleware OriginCheck: state-changing запросы с чужого Origin отклоняются
  (AGENT.md 9.7); запросы без Origin пропускаются — cookie уже SameSite=Lax
- CSP перечисляет директивы явно (script-src/worker-src/manifest-src 'self',
  style-src с inline для React, img-src с внешними https для аватаров вебхуков,
  connect-src с доменом файлов и LiveKit); добавлены Permissions-Policy,
  Cross-Origin-Opener-Policy и X-Permitted-Cross-Domain-Policies
- лимиты по AGENT.md 8.6: загрузки 10/мин и 100/сутки (вложения и аватары),
  реакции 20/мин; администратор инстанса лимиты обходит
- step-up: смена прав роли (в теле PATCH) и удаление сервера (перед вызовом
  /auth/step-up) требуют свежего подтверждения личности
- тесты: Origin (свой/чужой/GET), состав CSP, лимит реакций, step-up на права
  роли; исправлен вызов NewRateLimiter (второй аргумент — burst, не окно)
This commit is contained in:
2026-09-22 00:40:10 +03:00
parent 1cb949a7a4
commit af03cfb063
8 changed files with 302 additions and 17 deletions
+138
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"glchat/internal/store"
@@ -945,3 +946,140 @@ func TestInstanceMediaLimitsEnforced(t *testing.T) {
t.Fatalf("возврат лимита = %d", rec.Code)
}
}
func TestOriginCheckRejectsForeignOrigin(t *testing.T) {
srv, owner, _, _, _ := cosmeticsFixture(t)
// Свой Origin проходит.
allowed := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/guilds",
strings.NewReader(`{"name":"Оригин"}`))
allowed.Header.Set("Content-Type", "application/json")
allowed.Header.Set("Origin", "https://gl.mhspx.su")
allowed.AddCookie(owner)
allowedRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(allowedRec, allowed)
if allowedRec.Code != http.StatusOK {
t.Fatalf("свой Origin = %d (%s)", allowedRec.Code, allowedRec.Body.String())
}
// Чужой Origin отклоняется до обработчика.
foreign := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/v1/guilds",
strings.NewReader(`{"name":"Чужой"}`))
foreign.Header.Set("Content-Type", "application/json")
foreign.Header.Set("Origin", "https://evil.example")
foreign.AddCookie(owner)
foreignRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(foreignRec, foreign)
if foreignRec.Code != http.StatusForbidden {
t.Fatalf("чужой Origin = %d, ожидался 403 (%s)", foreignRec.Code, foreignRec.Body.String())
}
if code := errorCodeOf(t, foreignRec); code != "origin.forbidden" {
t.Fatalf("код ошибки = %q", code)
}
// GET с чужого Origin не блокируем: состояние он не меняет.
read := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/users/@me", nil)
read.Header.Set("Origin", "https://evil.example")
read.AddCookie(owner)
readRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(readRec, read)
if readRec.Code != http.StatusOK {
t.Fatalf("GET с чужого Origin = %d", readRec.Code)
}
}
func TestSecurityHeadersPolicy(t *testing.T) {
srv, _ := newTestServer(t)
rec := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
policy := rec.Header().Get("Content-Security-Policy")
for _, directive := range []string{
"default-src 'self'",
"script-src 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"connect-src 'self'",
} {
if !strings.Contains(policy, directive) {
t.Fatalf("в CSP нет %q: %s", directive, policy)
}
}
if strings.Contains(policy, "unsafe-eval") {
t.Fatalf("CSP разрешает unsafe-eval: %s", policy)
}
if got := rec.Header().Get("Permissions-Policy"); !strings.Contains(got, "camera=(self)") {
t.Fatalf("Permissions-Policy = %q", got)
}
if rec.Header().Get("Cross-Origin-Opener-Policy") != "same-origin" {
t.Fatalf("COOP = %q", rec.Header().Get("Cross-Origin-Opener-Policy"))
}
}
func TestUploadAndReactionLimits(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
channelID := guildChannelID(t, srv, guildID, owner)
// Реакции: 20 в минуту на пользователя (AGENT.md 8.6).
message := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages",
`{"content":"реакции"}`, owner)
messageID := decodeResponse[struct {
Message struct {
ID string `json:"id"`
} `json:"message"`
}](t, message).Message.ID
limited := false
for i := 0; i < 25; i++ {
emoji := url.PathEscape("👍")
rec := doJSON(t, srv, http.MethodPut,
"/api/v1/channels/"+channelID+"/messages/"+messageID+"/reactions/"+emoji, "", owner)
if rec.Code == http.StatusTooManyRequests {
limited = true
break
}
}
if !limited {
t.Fatal("лимит реакций не сработал")
}
}
func TestRolePermissionChangeRequiresStepUp(t *testing.T) {
srv, owner, _, guildID, _ := cosmeticsFixture(t)
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/roles",
`{"name":"Тихая"}`, owner)
roleID := decodeResponse[struct {
Role struct {
ID string `json:"id"`
} `json:"role"`
}](t, created).Role.ID
// Смена прав без подтверждения отклоняется.
denied := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/roles/"+roleID,
`{"permissions":"VIEW_GUILD|SEND_MESSAGES"}`, owner)
if denied.Code != http.StatusForbidden {
t.Fatalf("смена прав без step-up = %d (%s)", denied.Code, denied.Body.String())
}
if code := errorCodeOf(t, denied); code != "auth.step_up_required" {
t.Fatalf("код ошибки = %q", code)
}
// Переименование роли step-up не требует: права не меняются.
renamed := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/roles/"+roleID,
`{"name":"Громкая"}`, owner)
if renamed.Code != http.StatusOK {
t.Fatalf("переименование роли = %d (%s)", renamed.Code, renamed.Body.String())
}
// После подтверждения паролем права меняются.
stepped := doJSON(t, srv, http.MethodPost, "/api/v1/auth/step-up",
`{"password":"correct-horse-battery"}`, owner)
if stepped.Code != http.StatusOK {
t.Fatalf("step-up = %d (%s)", stepped.Code, stepped.Body.String())
}
updated := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guildID+"/roles/"+roleID,
`{"permissions":"VIEW_GUILD|SEND_MESSAGES"}`, owner)
if updated.Code != http.StatusOK {
t.Fatalf("смена прав после step-up = %d (%s)", updated.Code, updated.Body.String())
}
}