af03cfb063
- 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, не окно)
280 lines
8.9 KiB
Go
280 lines
8.9 KiB
Go
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"))
|
||
})
|
||
}
|
||
}
|