check / check (push) Failing after 39s
realIP took the first X-Forwarded-For entry, which the client itself can write, so behind a proxy that appends to the header a client chose the address dnswatcher logs and the /metrics rate limit counts. It now walks the entries from the right past trusted proxies, using the existing trusted-proxy check, and takes the first that is not one; the leftmost when all are. All X-Forwarded-For header lines are read as one list, since a proxy may add its own line instead of appending to the client's. An empty entry where the client address belongs falls back to the peer address, as an empty first entry did before. X-Real-IP is unchanged. Model: opus-5-5
343 lines
9.0 KiB
Go
343 lines
9.0 KiB
Go
// Package middleware provides HTTP middleware.
|
|
package middleware
|
|
|
|
import (
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/99designs/basicauth-go"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"github.com/go-chi/httprate"
|
|
"go.uber.org/fx"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/config"
|
|
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
)
|
|
|
|
// corsMaxAge is the maximum age for CORS preflight responses.
|
|
const corsMaxAge = 300
|
|
|
|
// Rate limit for /metrics: each client address may send
|
|
// metricsRequestLimit requests per metricsRequestWindow. Every request
|
|
// counts, so password guessing gets at most 30 tries a minute per
|
|
// address. One Prometheus server scraping every 15 seconds sends 4
|
|
// requests a minute, and two scraping every 5 seconds from one address
|
|
// send 24, so normal scraping stays under the limit.
|
|
const (
|
|
metricsRequestLimit = 30
|
|
metricsRequestWindow = time.Minute
|
|
)
|
|
|
|
// Security response header values applied to every response.
|
|
//
|
|
// The CSP is as strict as the dashboard allows: the template ships no
|
|
// JavaScript, no inline styles, no inline event handlers and no images,
|
|
// and its only subresource is the embedded stylesheet at
|
|
// /s/css/tailwind.min.css, which style-src 'self' permits. Neither
|
|
// unsafe-inline nor unsafe-eval is used. frame-ancestors 'none' is the
|
|
// primary anti-framing control; X-Frame-Options is the legacy fallback.
|
|
const (
|
|
// hstsValue is emitted unconditionally, including over plain HTTP,
|
|
// because the service runs behind a TLS-terminating proxy and the
|
|
// browser must still enforce HTTPS end to end.
|
|
hstsValue = "max-age=31536000; includeSubDomains"
|
|
|
|
cspValue = "default-src 'self'; " +
|
|
"script-src 'none'; " +
|
|
"style-src 'self'; " +
|
|
"img-src 'self'; " +
|
|
"font-src 'none'; " +
|
|
"connect-src 'none'; " +
|
|
"object-src 'none'; " +
|
|
"base-uri 'none'; " +
|
|
"form-action 'none'; " +
|
|
"frame-ancestors 'none'"
|
|
|
|
frameOptionsValue = "DENY"
|
|
|
|
contentTypeOptionsValue = "nosniff"
|
|
|
|
// referrerPolicyValue is stricter than the policy minimum of
|
|
// strict-origin-when-cross-origin: the dashboard has no
|
|
// cross-origin navigation needs and its URL may name internal
|
|
// hosts.
|
|
referrerPolicyValue = "no-referrer"
|
|
|
|
permissionsPolicyValue = "accelerometer=(), " +
|
|
"autoplay=(), " +
|
|
"camera=(), " +
|
|
"display-capture=(), " +
|
|
"encrypted-media=(), " +
|
|
"fullscreen=(), " +
|
|
"geolocation=(), " +
|
|
"gyroscope=(), " +
|
|
"magnetometer=(), " +
|
|
"microphone=(), " +
|
|
"midi=(), " +
|
|
"payment=(), " +
|
|
"picture-in-picture=(), " +
|
|
"publickey-credentials-get=(), " +
|
|
"screen-wake-lock=(), " +
|
|
"usb=(), " +
|
|
"xr-spatial-tracking=()"
|
|
)
|
|
|
|
// Params contains dependencies for Middleware.
|
|
type Params struct {
|
|
fx.In
|
|
|
|
Logger *logger.Logger
|
|
Globals *globals.Globals
|
|
Config *config.Config
|
|
}
|
|
|
|
// Middleware provides HTTP middleware.
|
|
type Middleware struct {
|
|
log *slog.Logger
|
|
params *Params
|
|
}
|
|
|
|
// New creates a new Middleware instance.
|
|
func New(
|
|
_ fx.Lifecycle,
|
|
params Params,
|
|
) (*Middleware, error) {
|
|
return &Middleware{
|
|
log: params.Logger.Get(),
|
|
params: ¶ms,
|
|
}, nil
|
|
}
|
|
|
|
// loggingResponseWriter wraps http.ResponseWriter to capture status.
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
|
|
statusCode int
|
|
}
|
|
|
|
func newLoggingResponseWriter(
|
|
writer http.ResponseWriter,
|
|
) *loggingResponseWriter {
|
|
return &loggingResponseWriter{writer, http.StatusOK}
|
|
}
|
|
|
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|
lrw.statusCode = code
|
|
lrw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
// Logging returns a request logging middleware.
|
|
func (m *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 := middleware.GetReqID(ctx)
|
|
m.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", realIP(request),
|
|
"status", lrw.statusCode,
|
|
"latency_ms", latency.Milliseconds(),
|
|
)
|
|
}()
|
|
|
|
next.ServeHTTP(lrw, request)
|
|
})
|
|
}
|
|
}
|
|
|
|
func ipFromHostPort(hostPort string) string {
|
|
host, _, err := net.SplitHostPort(hostPort)
|
|
if err != nil {
|
|
return hostPort
|
|
}
|
|
|
|
return host
|
|
}
|
|
|
|
// trustedProxyNets are RFC1918 and loopback CIDRs.
|
|
//
|
|
//nolint:gochecknoglobals // package-level constant nets parsed once
|
|
var trustedProxyNets = func() []*net.IPNet {
|
|
cidrs := []string{
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"127.0.0.0/8",
|
|
"::1/128",
|
|
"fc00::/7",
|
|
}
|
|
|
|
nets := make([]*net.IPNet, 0, len(cidrs))
|
|
|
|
for _, cidr := range cidrs {
|
|
_, n, _ := net.ParseCIDR(cidr)
|
|
nets = append(nets, n)
|
|
}
|
|
|
|
return nets
|
|
}()
|
|
|
|
func isTrustedProxy(ip net.IP) bool {
|
|
for _, n := range trustedProxyNets {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// realIP extracts the client's real IP address from the request.
|
|
// Proxy headers are only trusted from RFC1918/loopback addresses.
|
|
//
|
|
// Each proxy adds to the end of X-Forwarded-For the address it got the
|
|
// request from, so the client can write every entry before the one the
|
|
// first trusted proxy added. The client address is therefore the
|
|
// rightmost entry that is not a trusted proxy, or the leftmost entry
|
|
// when they all are.
|
|
func realIP(r *http.Request) string {
|
|
addr := ipFromHostPort(r.RemoteAddr)
|
|
remoteIP := net.ParseIP(addr)
|
|
|
|
if remoteIP == nil || !isTrustedProxy(remoteIP) {
|
|
return addr
|
|
}
|
|
|
|
if ip := strings.TrimSpace(
|
|
r.Header.Get("X-Real-IP"),
|
|
); ip != "" {
|
|
return ip
|
|
}
|
|
|
|
// A proxy may add its entry as a header line of its own instead of
|
|
// appending to the line the client sent, so all lines form one list.
|
|
entries := strings.Split(
|
|
strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",",
|
|
)
|
|
client := strings.TrimSpace(entries[0])
|
|
|
|
for i := len(entries) - 1; i > 0; i-- {
|
|
entry := strings.TrimSpace(entries[i])
|
|
if !isTrustedProxy(net.ParseIP(entry)) {
|
|
client = entry
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if client != "" {
|
|
return client
|
|
}
|
|
|
|
return addr
|
|
}
|
|
|
|
// CORS returns middleware that lets any origin read a response. It is
|
|
// for the public, read-only routes only, so it allows only the
|
|
// methods those routes serve and no Authorization header.
|
|
func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
|
return cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{"GET", "OPTIONS"},
|
|
AllowedHeaders: []string{"Accept", "Content-Type"},
|
|
ExposedHeaders: []string{"Link"},
|
|
AllowCredentials: false,
|
|
MaxAge: corsMaxAge,
|
|
})
|
|
}
|
|
|
|
// SecurityHeaders returns middleware that sets the security response
|
|
// headers required for production internet exposure on every response.
|
|
//
|
|
// The headers are set before the request reaches the next handler so
|
|
// that they are present on every response, including panics recovered
|
|
// by chi's Recoverer and timeouts produced by chi's Timeout.
|
|
func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(
|
|
writer http.ResponseWriter,
|
|
request *http.Request,
|
|
) {
|
|
header := writer.Header()
|
|
header.Set("Strict-Transport-Security", hstsValue)
|
|
header.Set("Content-Security-Policy", cspValue)
|
|
header.Set("X-Frame-Options", frameOptionsValue)
|
|
header.Set(
|
|
"X-Content-Type-Options",
|
|
contentTypeOptionsValue,
|
|
)
|
|
header.Set("Referrer-Policy", referrerPolicyValue)
|
|
header.Set(
|
|
"Permissions-Policy",
|
|
permissionsPolicyValue,
|
|
)
|
|
|
|
next.ServeHTTP(writer, request)
|
|
})
|
|
}
|
|
}
|
|
|
|
// MetricsRateLimit returns middleware for /metrics that answers 429
|
|
// Too Many Requests to a client address over the rate limit. The
|
|
// address is the one realIP works out, so a client that is not a
|
|
// trusted proxy cannot get a fresh allowance by sending its own
|
|
// X-Real-IP or X-Forwarded-For. CanonicalizeIP counts all IPv6
|
|
// addresses in one /64 as one client, since a client usually holds a
|
|
// whole /64. An IPv4 address a proxy reports in IPv6-mapped form
|
|
// (::ffff:203.0.113.1) is turned back into plain IPv4 first, as every
|
|
// such address is in the same /64.
|
|
func (m *Middleware) MetricsRateLimit() func(http.Handler) http.Handler {
|
|
return httprate.LimitBy(
|
|
metricsRequestLimit,
|
|
metricsRequestWindow,
|
|
func(request *http.Request) (string, error) {
|
|
ip := realIP(request)
|
|
|
|
addr, err := netip.ParseAddr(ip)
|
|
if err == nil {
|
|
ip = addr.Unmap().String()
|
|
}
|
|
|
|
return httprate.CanonicalizeIP(ip), nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// MetricsAuth returns basic auth middleware for /metrics.
|
|
func (m *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|
if m.params.Config.MetricsUsername == "" {
|
|
return func(next http.Handler) http.Handler {
|
|
return next
|
|
}
|
|
}
|
|
|
|
return basicauth.New(
|
|
"metrics",
|
|
map[string][]string{
|
|
m.params.Config.MetricsUsername: {
|
|
m.params.Config.MetricsPassword,
|
|
},
|
|
},
|
|
)
|
|
}
|