diff --git a/internal/httpx/errors.go b/internal/httpx/errors.go index fc6055d..ce0f6f8 100644 --- a/internal/httpx/errors.go +++ b/internal/httpx/errors.go @@ -14,6 +14,8 @@ const ( CodeBadRequest ErrorCode = "request.bad" CodeRateLimited ErrorCode = "ratelimit.hit" CodeNotReady ErrorCode = "instance.not_ready" + // CodeOriginForbidden — state-changing запрос пришёл с чужого Origin. + CodeOriginForbidden ErrorCode = "origin.forbidden" ) type Error struct { @@ -38,6 +40,8 @@ func (e Error) HTTPStatus() int { return http.StatusTooManyRequests case CodeNotReady: return http.StatusServiceUnavailable + case CodeOriginForbidden: + return http.StatusForbidden default: return http.StatusInternalServerError } diff --git a/internal/httpx/httpx_test.go b/internal/httpx/httpx_test.go index f854d8b..032f066 100644 --- a/internal/httpx/httpx_test.go +++ b/internal/httpx/httpx_test.go @@ -100,7 +100,7 @@ func TestRecovererTurnsPanicIntoError(t *testing.T) { func TestSecurityHeaders(t *testing.T) { 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) })).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") 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) })).ServeHTTP(rec, req) diff --git a/internal/httpx/middleware.go b/internal/httpx/middleware.go index 4457a0a..9b6fbbf 100644 --- a/internal/httpx/middleware.go +++ b/internal/httpx/middleware.go @@ -118,26 +118,72 @@ func Recoverer(logger *slog.Logger) Middleware { } } -func SecurityHeaders(filesDomain string) Middleware { - frameAncestors := "'none'" - csp := strings.Join([]string{ +// SecurityOptions — параметры заголовков безопасности (AGENT.md 9.7). +type SecurityOptions struct { + // 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'", "base-uri 'self'", "object-src 'none'", - "frame-ancestors " + frameAncestors, + "frame-ancestors 'none'", "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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Cross-Origin-Resource-Policy", "same-site") - if isTLS(r) { - h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") - } + h.Set("Cross-Origin-Opener-Policy", "same-origin") + 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) }) } @@ -193,3 +239,41 @@ func RequestIDFrom(ctx context.Context) string { } 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")) + }) + } +} diff --git a/internal/server/api_files.go b/internal/server/api_files.go index 5ce4af7..6a15cf0 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -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 == "" { diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go index 1bd63e0..e493651 100644 --- a/internal/server/api_guilds.go +++ b/internal/server/api_guilds.go @@ -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 diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 4068992..59a0475 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -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") diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index 7f475fc..6cf7104 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -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()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b5aaaa0..8041e75 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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),