// Package middleware provides HTTP middleware for the neoirc server. package middleware import ( "log/slog" "net" "net/http" "net/netip" "slices" "strings" "time" basicauth "github.com/99designs/basicauth-go" chimw "github.com/go-chi/chi/v5/middleware" metrics "github.com/slok/go-http-metrics/metrics/prometheus" ghmm "github.com/slok/go-http-metrics/middleware" "github.com/slok/go-http-metrics/middleware/std" "github.com/spf13/viper" "go.uber.org/fx" "go4.org/netipx" "sneak.berlin/go/neoirc/internal/config" "sneak.berlin/go/neoirc/internal/globals" "sneak.berlin/go/neoirc/internal/logger" ) // Params defines the dependencies for creating Middleware. type Params struct { fx.In Logger *logger.Logger Globals *globals.Globals Config *config.Config } // Middleware provides HTTP middleware handlers. type Middleware struct { log *slog.Logger params *Params } // New creates a new Middleware instance. func New( _ fx.Lifecycle, params Params, ) (*Middleware, error) { mware := &Middleware{ params: ¶ms, log: params.Logger.Get(), } return mware, nil } func ipFromHostPort(hostPort string) string { host, _, err := net.SplitHostPort(hostPort) if err != nil { return "" } if len(host) > 0 && host[0] == '[' { return host[1 : len(host)-1] } return host } // ClientIP returns middleware that replaces request.RemoteAddr with the // client's address and keeps the port of the TCP connection. The // X-Forwarded-For and X-Real-IP headers count only when the connection // comes from an address in TRUSTED_PROXIES. func (mware *Middleware) ClientIP() func(http.Handler) http.Handler { trusted := mware.params.Config.TrustedProxies return func(next http.Handler) http.Handler { return http.HandlerFunc( func( writer http.ResponseWriter, request *http.Request, ) { peer, err := netip.ParseAddrPort(request.RemoteAddr) if err == nil { client := clientAddr( peer.Addr().Unmap(), request.Header, trusted, ) request.RemoteAddr = netip.AddrPortFrom( client, peer.Port(), ).String() } next.ServeHTTP(writer, request) }) } } // clientAddr returns the client's address for a request from peer. A // peer outside trusted is the client, whatever its headers say. A // trusted peer is a proxy. Its X-Forwarded-For list is read from the // right, past the trusted addresses, to the first untrusted one; when // all are trusted, the client is the leftmost. Without X-Forwarded-For, // the client is X-Real-IP. An entry that is not an IP address gives // peer. func clientAddr( peer netip.Addr, header http.Header, trusted *netipx.IPSet, ) netip.Addr { if !trusted.Contains(peer) { return peer } forwarded := strings.Join(header.Values("X-Forwarded-For"), ",") if forwarded == "" { realIP, err := netip.ParseAddr( strings.TrimSpace(header.Get("X-Real-IP")), ) if err != nil { return peer } return realIP.Unmap() } client := peer for _, entry := range slices.Backward(strings.Split(forwarded, ",")) { addr, err := netip.ParseAddr(strings.TrimSpace(entry)) if err != nil { return peer } client = addr.Unmap() if !trusted.Contains(client) { break } } return client } type loggingResponseWriter struct { http.ResponseWriter statusCode int } // newLoggingResponseWriter wraps a ResponseWriter // to capture the status code. func newLoggingResponseWriter( writer http.ResponseWriter, ) *loggingResponseWriter { return &loggingResponseWriter{ ResponseWriter: writer, statusCode: http.StatusOK, } } func (lrw *loggingResponseWriter) WriteHeader(code int) { lrw.statusCode = code lrw.ResponseWriter.WriteHeader(code) } // Logging returns middleware that logs each HTTP request. func (mware *Middleware) Logging() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc( func( writer http.ResponseWriter, request *http.Request, ) { start := time.Now() lrw := newLoggingResponseWriter(writer) ctx := request.Context() defer func() { latency := time.Since(start) reqID, _ := ctx.Value( chimw.RequestIDKey, ).(string) mware.log.InfoContext( ctx, "request", "request_start", start, "method", request.Method, "url", request.URL.String(), "useragent", request.UserAgent(), "request_id", reqID, "referer", request.Referer(), "proto", request.Proto, "remoteIP", ipFromHostPort(request.RemoteAddr), "status", lrw.statusCode, "latency_ms", latency.Milliseconds(), ) }() next.ServeHTTP(lrw, request) }) } } // Metrics returns middleware that records HTTP metrics. func (mware *Middleware) Metrics() func(http.Handler) http.Handler { metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields Recorder: metrics.NewRecorder( metrics.Config{}, //nolint:exhaustruct // defaults ), }) return func(next http.Handler) http.Handler { return std.Handler("", metricsMiddleware, next) } } // MetricsAuth returns middleware that protects metrics with basic auth. func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler { return basicauth.New( "metrics", map[string][]string{ viper.GetString("METRICS_USERNAME"): { viper.GetString("METRICS_PASSWORD"), }, }, ) } // cspPolicy is the Content-Security-Policy header value applied to all // responses. The embedded SPA loads scripts and styles from same-origin // files only (no inline scripts or inline style attributes), so a strict // policy works without 'unsafe-inline'. const cspPolicy = "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "connect-src 'self'; " + "img-src 'self'; " + "font-src 'self'; " + "object-src 'none'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" // CSP returns middleware that sets the Content-Security-Policy header on // every response for defense-in-depth against XSS. func (mware *Middleware) CSP() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc( func( writer http.ResponseWriter, request *http.Request, ) { writer.Header().Set( "Content-Security-Policy", cspPolicy, ) next.ServeHTTP(writer, request) }) } }