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")) }) } }