Files
grendervill af03cfb063 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, не окно)
2026-09-22 00:40:10 +03:00

280 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpx
import (
"bufio"
"context"
"log/slog"
"net"
"net/http"
"strings"
"time"
)
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
if r.status == 0 {
r.status = http.StatusOK
}
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// Unwrap отдаёт исходный ResponseWriter: http.ResponseController и другие
// обёртки должны видеть возможности базового писателя.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// Hijack пробрасывает перехват соединения: без него не работает апгрейд на
// WebSocket через /gateway (AGENT.md 8.3).
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := r.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
return hijacker.Hijack()
}
// RequestInfoMiddleware кладёт IP и User-Agent запроса в контекст: huma-хендлеры
// не получают *http.Request, а сервисам эти данные нужны (аудит, безопасность).
func RequestInfoMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r.WithContext(WithRequestInfo(r.Context(), r)))
})
}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = newRequestID()
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(withRequestID(r.Context(), id)))
})
}
func Logger(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if rec.status == 0 {
rec.status = http.StatusOK
}
logger.LogAttrs(r.Context(), slog.LevelInfo, "http request",
slog.String("request_id", RequestIDFrom(r.Context())),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Int("bytes", rec.bytes),
slog.String("remote_ip", ClientIP(r, nil)),
slog.Duration("duration", time.Since(start)),
)
})
}
}
func Recoverer(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.ErrorContext(r.Context(), "panic recovered",
slog.String("request_id", RequestIDFrom(r.Context())),
slog.String("path", r.URL.Path),
slog.Any("panic", rec),
)
WriteError(w, NewError(CodeInternalError, "internal error"))
}
}()
next.ServeHTTP(w, r)
})
}
}
// 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 '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()
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")
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)
})
}
}
func isTLS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func ClientIP(r *http.Request, trustedProxies []*net.IPNet) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
proxyTrusted := false
for _, n := range trustedProxies {
if ip := net.ParseIP(host); ip != nil && n.Contains(ip) {
proxyTrusted = true
break
}
}
if !proxyTrusted {
return host
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Caddy дописывает в конец цепочки адрес непосредственного клиента,
// поэтому при одном доверенном прокси последний элемент и есть клиент.
parts := strings.Split(xff, ",")
candidate := strings.TrimSpace(parts[len(parts)-1])
if ip := net.ParseIP(candidate); ip != nil {
return ip.String()
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-Ip")); realIP != "" {
if ip := net.ParseIP(realIP); ip != nil {
return ip.String()
}
}
return host
}
type ctxKey int
const requestIDKey ctxKey = iota
func withRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
func RequestIDFrom(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
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"))
})
}
}