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