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
+25
View File
@@ -84,6 +84,10 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
return
}
ctx := r.Context()
if err := s.checkUploadLimit(currentUser); err != nil {
writeHumaAPIError(w, err)
return
}
limit := s.mediaLimit(ctx, mediaAvatar)
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
@@ -203,6 +207,11 @@ func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
return
}
ctx := r.Context()
// Загрузки: 10 в минуту и 100 в сутки на пользователя (AGENT.md 8.6).
if err := s.checkUploadLimit(currentUser); err != nil {
writeHumaAPIError(w, err)
return
}
channelID, _, channel, err := s.requireChannelPermission(ctx, chi.URLParam(r, "channel_id"), currentUser, permissions.AttachFiles)
if err != nil {
writeHumaAPIError(w, err)
@@ -383,6 +392,22 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
return file, nil
}
// checkUploadLimit применяет лимиты загрузок: минута и сутки (AGENT.md 8.6).
// Администратор инстанса лимит обходит — как и в отправке сообщений.
func (s *Server) checkUploadLimit(user *store.User) error {
if user.IsInstanceAdmin {
return nil
}
key := formatSnowflake(user.ID)
if allowed, retryAfter := s.uploadLimiter.Allow("upload:" + key); !allowed {
return rateLimitedError(retryAfter)
}
if allowed, retryAfter := s.uploadDailyLimiter.Allow("upload-day:" + key); !allowed {
return rateLimitedError(retryAfter)
}
return nil
}
// saveUpload пишет файл на диск и регистрирует его в БД.
func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Reader) (*store.File, error) {
if s.cfg.DataDir == "" {
+17 -2
View File
@@ -332,7 +332,7 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
GuildID string `path:"guild_id"`
},
) (*okOutput, error) {
user, _, err := requireUser(ctx)
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
@@ -343,6 +343,11 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
if !resolved.IsOwner && !user.IsInstanceAdmin {
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the owner can delete the guild")
}
// Удаление сервера требует свежего подтверждения личности (AGENT.md 9.3).
// Тело у DELETE не спрашиваем: клиент сначала вызывает /auth/step-up.
if err := s.auth.RequireStepUp(ctx, user, session, "", ""); err != nil {
return nil, humaError(err)
}
if err := s.deleteGuild(ctx, user, guildID); err != nil {
return nil, err
}
@@ -1274,10 +1279,13 @@ func (s *Server) registerRoleRoutes(api huma.API) {
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
NickColor *int64 `json:"nick_color,omitempty"`
NickEffect *string `json:"nick_effect,omitempty"`
// Step-up обязателен при смене прав роли (AGENT.md 9.3).
StepUpPassword string `json:"step_up_password,omitempty"`
StepUpTOTP string `json:"step_up_totp,omitempty"`
}
},
) (*roleOutput, error) {
user, _, err := requireUser(ctx)
user, session, err := requireUser(ctx)
if err != nil {
return nil, err
}
@@ -1285,6 +1293,13 @@ func (s *Server) registerRoleRoutes(api huma.API) {
if err != nil {
return nil, err
}
// Смена набора прав роли — чувствительная операция: подтверждаем
// личность свежим паролем (AGENT.md 9.3).
if input.Body.Permissions != nil {
if err := s.auth.RequireStepUp(ctx, user, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil {
return nil, humaError(err)
}
}
roleID, err := parseID("role_id", input.RoleID)
if err != nil {
return nil, err
+4
View File
@@ -760,6 +760,10 @@ func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID,
if err != nil {
return nil, err
}
// Реакции: 20 в минуту на пользователя (AGENT.md 8.6).
if allowed, retryAfter := s.reactionLimiter.Allow("reaction:" + formatSnowflake(user.ID)); !allowed {
return nil, rateLimitedError(retryAfter)
}
emoji = strings.TrimSpace(emoji)
if emoji == "" || len([]rune(emoji)) > 64 {
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji is invalid")
+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())
}
}
+19 -4
View File
@@ -61,6 +61,12 @@ type Server struct {
inviteLimiter *httpx.RateLimiter
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
webhookLimiter *httpx.RateLimiter
// uploadLimiter и uploadDailyLimiter — 10 загрузок в минуту и 100 в сутки
// на пользователя (AGENT.md 8.6).
uploadLimiter *httpx.RateLimiter
uploadDailyLimiter *httpx.RateLimiter
// reactionLimiter — 20 реакций в минуту на пользователя.
reactionLimiter *httpx.RateLimiter
// slowmode — время последней отправки в комнату для режима медленной
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
slowmodeMu sync.Mutex
@@ -111,9 +117,13 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
// Второй аргумент NewRateLimiter — запас (burst), а не окно.
uploadLimiter: httpx.NewRateLimiter(10, 10),
uploadDailyLimiter: httpx.NewRateLimiterWindow(100, 24*time.Hour, 100),
reactionLimiter: httpx.NewRateLimiter(20, 20),
slowmode: map[string]time.Time{},
presence: map[uint64]time.Time{},
webhookSeen: map[string]time.Time{},
// Дашборд опрашивает метрики раз в секунду: 120/мин с запасом.
metricsLimiter: httpx.NewRateLimiter(120, 30),
sysinfoPaths: sysinfo.DefaultPaths(),
@@ -135,6 +145,8 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
apiRouter.Use(s.sessionContext)
// Метрики дашборда вне общего лимита: у них свой (см. api_metrics.go).
apiRouter.Use(s.apiLimiter.MiddlewareExcept(apiRateLimitKey, metricsPath))
// Строгая проверка Origin для state-changing запросов (AGENT.md 9.7).
apiRouter.Use(httpx.OriginCheck(cfg.AllowedOrigins()))
s.api = s.registerAPI(apiRouter)
s.registerMetaRoutes(s.api)
s.registerAuthRoutes(apiRouter)
@@ -163,7 +175,10 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
s.registerRoutes(router)
handler := httpx.Chain(router,
httpx.SecurityHeaders(cfg.FilesDomain),
httpx.SecurityHeaders(httpx.SecurityOptions{
FilesDomain: cfg.FilesDomain,
LiveKitURL: cfg.LiveKitURL,
}),
httpx.RequestID,
httpx.RequestInfoMiddleware,
httpx.Logger(logger),