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:
@@ -14,6 +14,8 @@ const (
|
|||||||
CodeBadRequest ErrorCode = "request.bad"
|
CodeBadRequest ErrorCode = "request.bad"
|
||||||
CodeRateLimited ErrorCode = "ratelimit.hit"
|
CodeRateLimited ErrorCode = "ratelimit.hit"
|
||||||
CodeNotReady ErrorCode = "instance.not_ready"
|
CodeNotReady ErrorCode = "instance.not_ready"
|
||||||
|
// CodeOriginForbidden — state-changing запрос пришёл с чужого Origin.
|
||||||
|
CodeOriginForbidden ErrorCode = "origin.forbidden"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Error struct {
|
type Error struct {
|
||||||
@@ -38,6 +40,8 @@ func (e Error) HTTPStatus() int {
|
|||||||
return http.StatusTooManyRequests
|
return http.StatusTooManyRequests
|
||||||
case CodeNotReady:
|
case CodeNotReady:
|
||||||
return http.StatusServiceUnavailable
|
return http.StatusServiceUnavailable
|
||||||
|
case CodeOriginForbidden:
|
||||||
|
return http.StatusForbidden
|
||||||
default:
|
default:
|
||||||
return http.StatusInternalServerError
|
return http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func TestRecovererTurnsPanicIntoError(t *testing.T) {
|
|||||||
|
|
||||||
func TestSecurityHeaders(t *testing.T) {
|
func TestSecurityHeaders(t *testing.T) {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
SecurityHeaders("files.example.com")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
SecurityHeaders(SecurityOptions{FilesDomain: "files.example.com"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
|
})).ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil))
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ func TestSecurityHeadersAddHSTSBehindProxy(t *testing.T) {
|
|||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
SecurityHeaders("files.example.com")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
SecurityHeaders(SecurityOptions{FilesDomain: "files.example.com"})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})).ServeHTTP(rec, req)
|
})).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
|||||||
@@ -118,26 +118,72 @@ func Recoverer(logger *slog.Logger) Middleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SecurityHeaders(filesDomain string) Middleware {
|
// SecurityOptions — параметры заголовков безопасности (AGENT.md 9.7).
|
||||||
frameAncestors := "'none'"
|
type SecurityOptions struct {
|
||||||
csp := strings.Join([]string{
|
// FilesDomain — отдельный домен файлов: он попадает в img-src.
|
||||||
|
FilesDomain string
|
||||||
|
// LiveKitURL — адрес SFU: клиент подключается к нему по WebSocket.
|
||||||
|
LiveKitURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// cspPolicy собирает Content-Security-Policy. Директивы перечислены явно:
|
||||||
|
// скрипты и воркер только свои, стили допускают inline (React ставит style
|
||||||
|
// атрибуты), картинки — свои, внешние https (аватары вебхуков) и blob,
|
||||||
|
// соединения — свой origin, домен файлов и LiveKit.
|
||||||
|
func cspPolicy(options SecurityOptions, tls bool) string {
|
||||||
|
connect := []string{"'self'"}
|
||||||
|
if options.FilesDomain != "" {
|
||||||
|
connect = append(connect, "https://"+options.FilesDomain, "wss://"+options.FilesDomain)
|
||||||
|
}
|
||||||
|
if options.LiveKitURL != "" {
|
||||||
|
parsed := strings.TrimSuffix(options.LiveKitURL, "/")
|
||||||
|
parsed = strings.Replace(parsed, "https://", "wss://", 1)
|
||||||
|
parsed = strings.Replace(parsed, "http://", "ws://", 1)
|
||||||
|
connect = append(connect, parsed)
|
||||||
|
}
|
||||||
|
directives := []string{
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
"frame-ancestors " + frameAncestors,
|
"frame-ancestors 'none'",
|
||||||
"form-action 'self'",
|
"form-action 'self'",
|
||||||
}, "; ")
|
"script-src 'self'",
|
||||||
|
"worker-src 'self'",
|
||||||
|
"manifest-src 'self'",
|
||||||
|
"style-src 'self' 'unsafe-inline'",
|
||||||
|
"font-src 'self' data:",
|
||||||
|
"img-src 'self' data: blob: https:",
|
||||||
|
"media-src 'self' blob:",
|
||||||
|
"connect-src " + strings.Join(connect, " "),
|
||||||
|
}
|
||||||
|
if tls {
|
||||||
|
directives = append(directives, "upgrade-insecure-requests")
|
||||||
|
}
|
||||||
|
return strings.Join(directives, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SecurityHeaders(options SecurityOptions) Middleware {
|
||||||
|
secureCSP := cspPolicy(options, true)
|
||||||
|
plainCSP := cspPolicy(options, false)
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("Content-Security-Policy", csp)
|
if isTLS(r) {
|
||||||
|
h.Set("Content-Security-Policy", secureCSP)
|
||||||
|
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
} else {
|
||||||
|
h.Set("Content-Security-Policy", plainCSP)
|
||||||
|
}
|
||||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Cross-Origin-Resource-Policy", "same-site")
|
h.Set("Cross-Origin-Resource-Policy", "same-site")
|
||||||
if isTLS(r) {
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
h.Set("X-Permitted-Cross-Domain-Policies", "none")
|
||||||
}
|
// Голос и демонстрация экрана нужны самому приложению, остальное
|
||||||
|
// браузеру здесь не требуется.
|
||||||
|
h.Set("Permissions-Policy",
|
||||||
|
"camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=()")
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -193,3 +239,41 @@ func RequestIDFrom(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// stateChangingMethods — методы, которые меняют состояние: для них проверяем
|
||||||
|
// Origin, чтобы чужая страница не отправила запрос с cookie пользователя
|
||||||
|
// (AGENT.md 9.7, 8.1).
|
||||||
|
var stateChangingMethods = map[string]bool{
|
||||||
|
http.MethodPost: true,
|
||||||
|
http.MethodPut: true,
|
||||||
|
http.MethodPatch: true,
|
||||||
|
http.MethodDelete: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// OriginCheck отклоняет state-changing запросы с чужого Origin. Запросы без
|
||||||
|
// Origin (curl, CLI, десктоп с Bearer) пропускаем: браузер их не подделает, а
|
||||||
|
// cookie сессии помечена SameSite=Lax.
|
||||||
|
func OriginCheck(allowedOrigins []string) Middleware {
|
||||||
|
allowed := make(map[string]bool, len(allowedOrigins))
|
||||||
|
for _, origin := range allowedOrigins {
|
||||||
|
allowed[strings.ToLower(strings.TrimSuffix(origin, "/"))] = true
|
||||||
|
}
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !stateChangingMethods[r.Method] {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := strings.ToLower(strings.TrimSuffix(r.Header.Get("Origin"), "/"))
|
||||||
|
if origin == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if allowed[origin] {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteError(w, NewError(CodeOriginForbidden, "origin is not allowed"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ func (s *Server) handleAvatarUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
|
if err := s.checkUploadLimit(currentUser); err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
limit := s.mediaLimit(ctx, mediaAvatar)
|
limit := s.mediaLimit(ctx, mediaAvatar)
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
|
r.Body = http.MaxBytesReader(w, r.Body, limit+maxMultipartOverhead)
|
||||||
if err := r.ParseMultipartForm(maxMultipartMemory); err != nil { //nolint:gosec // объём ограничен MaxBytesReader
|
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
|
return
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
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)
|
channelID, _, channel, err := s.requireChannelPermission(ctx, chi.URLParam(r, "channel_id"), currentUser, permissions.AttachFiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeHumaAPIError(w, err)
|
writeHumaAPIError(w, err)
|
||||||
@@ -383,6 +392,22 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
|
|||||||
return file, nil
|
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 пишет файл на диск и регистрирует его в БД.
|
// saveUpload пишет файл на диск и регистрирует его в БД.
|
||||||
func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Reader) (*store.File, error) {
|
func (s *Server) saveUpload(ctx context.Context, file store.File, content io.Reader) (*store.File, error) {
|
||||||
if s.cfg.DataDir == "" {
|
if s.cfg.DataDir == "" {
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
GuildID string `path:"guild_id"`
|
GuildID string `path:"guild_id"`
|
||||||
},
|
},
|
||||||
) (*okOutput, error) {
|
) (*okOutput, error) {
|
||||||
user, _, err := requireUser(ctx)
|
user, session, err := requireUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -343,6 +343,11 @@ func (s *Server) registerGuildCoreRoutes(api huma.API) {
|
|||||||
if !resolved.IsOwner && !user.IsInstanceAdmin {
|
if !resolved.IsOwner && !user.IsInstanceAdmin {
|
||||||
return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the owner can delete the guild")
|
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 {
|
if err := s.deleteGuild(ctx, user, guildID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1274,10 +1279,13 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
|
CosmeticBadgeID *string `json:"cosmetic_badge_id,omitempty"`
|
||||||
NickColor *int64 `json:"nick_color,omitempty"`
|
NickColor *int64 `json:"nick_color,omitempty"`
|
||||||
NickEffect *string `json:"nick_effect,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) {
|
) (*roleOutput, error) {
|
||||||
user, _, err := requireUser(ctx)
|
user, session, err := requireUser(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1285,6 +1293,13 @@ func (s *Server) registerRoleRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
roleID, err := parseID("role_id", input.RoleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -760,6 +760,10 @@ func (s *Server) changeReaction(ctx context.Context, rawChannelID, rawMessageID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
emoji = strings.TrimSpace(emoji)
|
||||||
if emoji == "" || len([]rune(emoji)) > 64 {
|
if emoji == "" || len([]rune(emoji)) > 64 {
|
||||||
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji is invalid")
|
return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "emoji is invalid")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
@@ -945,3 +946,140 @@ func TestInstanceMediaLimitsEnforced(t *testing.T) {
|
|||||||
t.Fatalf("возврат лимита = %d", rec.Code)
|
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
|
inviteLimiter *httpx.RateLimiter
|
||||||
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
|
// webhookLimiter — 30 сообщений в минуту на вебхук (AGENT.md 7.11, 8.6).
|
||||||
webhookLimiter *httpx.RateLimiter
|
webhookLimiter *httpx.RateLimiter
|
||||||
|
// uploadLimiter и uploadDailyLimiter — 10 загрузок в минуту и 100 в сутки
|
||||||
|
// на пользователя (AGENT.md 8.6).
|
||||||
|
uploadLimiter *httpx.RateLimiter
|
||||||
|
uploadDailyLimiter *httpx.RateLimiter
|
||||||
|
// reactionLimiter — 20 реакций в минуту на пользователя.
|
||||||
|
reactionLimiter *httpx.RateLimiter
|
||||||
// slowmode — время последней отправки в комнату для режима медленной
|
// slowmode — время последней отправки в комнату для режима медленной
|
||||||
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
// отправки; словарь ограничен по размеру (AGENT.md 7.5).
|
||||||
slowmodeMu sync.Mutex
|
slowmodeMu sync.Mutex
|
||||||
@@ -111,6 +117,10 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
|
soundboardLimiter: httpx.NewRateLimiterWindow(3, 10*time.Second, 3),
|
||||||
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10),
|
||||||
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
|
webhookLimiter: httpx.NewRateLimiterWindow(webhookRateLimit, time.Minute, webhookRateLimit),
|
||||||
|
// Второй аргумент 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{},
|
slowmode: map[string]time.Time{},
|
||||||
presence: map[uint64]time.Time{},
|
presence: map[uint64]time.Time{},
|
||||||
webhookSeen: map[string]time.Time{},
|
webhookSeen: map[string]time.Time{},
|
||||||
@@ -135,6 +145,8 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
apiRouter.Use(s.sessionContext)
|
apiRouter.Use(s.sessionContext)
|
||||||
// Метрики дашборда вне общего лимита: у них свой (см. api_metrics.go).
|
// Метрики дашборда вне общего лимита: у них свой (см. api_metrics.go).
|
||||||
apiRouter.Use(s.apiLimiter.MiddlewareExcept(apiRateLimitKey, metricsPath))
|
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.api = s.registerAPI(apiRouter)
|
||||||
s.registerMetaRoutes(s.api)
|
s.registerMetaRoutes(s.api)
|
||||||
s.registerAuthRoutes(apiRouter)
|
s.registerAuthRoutes(apiRouter)
|
||||||
@@ -163,7 +175,10 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerRoutes(router)
|
s.registerRoutes(router)
|
||||||
|
|
||||||
handler := httpx.Chain(router,
|
handler := httpx.Chain(router,
|
||||||
httpx.SecurityHeaders(cfg.FilesDomain),
|
httpx.SecurityHeaders(httpx.SecurityOptions{
|
||||||
|
FilesDomain: cfg.FilesDomain,
|
||||||
|
LiveKitURL: cfg.LiveKitURL,
|
||||||
|
}),
|
||||||
httpx.RequestID,
|
httpx.RequestID,
|
||||||
httpx.RequestInfoMiddleware,
|
httpx.RequestInfoMiddleware,
|
||||||
httpx.Logger(logger),
|
httpx.Logger(logger),
|
||||||
|
|||||||
Reference in New Issue
Block a user