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
+4
View File
@@ -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
}
+2 -2
View File
@@ -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)
+93 -9
View File
@@ -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"))
})
}
}