check / check (push) Failing after 4s
The CORS middleware let every origin make credentialed requests, so a page on another subdomain of the server's domain could read and act as the signed-in user. Nothing needs cross-origin access, so the middleware and the go-chi/cors dependency go. A test checks that a request from another origin and a preflight get no Access-Control-Allow-* headers. Model: opus-5-5
257 lines
6.2 KiB
Go
257 lines
6.2 KiB
Go
// 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)
|
|
})
|
|
}
|
|
}
|