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 == "" {