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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user