check / check (push) Successful in 54s
Adds a SecurityHeaders middleware and registers it globally, right after the request ID middleware, so every route gets the headers, including static files, /metrics and error responses. It sets Strict-Transport-Security (one year, includeSubDomains), a Content-Security-Policy with default-src 'self', no scripts and frame-ancestors 'none', X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy no-referrer and a Permissions-Policy that turns every listed feature off. HSTS is sent on every response, not only over TLS: the service runs behind a TLS-terminating proxy and REPO_POLICIES.md requires the application to send it. Referrer-Policy is stricter than the policy baseline because dashboard URLs can name internal hosts. model: claude-opus-4-8 (implementation); claude-fable-5 (commit message)
291 lines
7.0 KiB
Go
291 lines
7.0 KiB
Go
// Package middleware provides HTTP middleware.
|
|
package middleware
|
|
|
|
import (
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/99designs/basicauth-go"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
"github.com/go-chi/cors"
|
|
"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
|
|
|
|
// 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.
|
|
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
|
|
}
|
|
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if parts := strings.SplitN(
|
|
xff, ",", 2, //nolint:mnd
|
|
); len(parts) > 0 {
|
|
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
}
|
|
|
|
return addr
|
|
}
|
|
|
|
// CORS returns CORS middleware.
|
|
func (m *Middleware) CORS() func(http.Handler) http.Handler {
|
|
return cors.Handler(cors.Options{
|
|
AllowedOrigins: []string{"*"},
|
|
AllowedMethods: []string{
|
|
"GET", "POST", "PUT", "DELETE", "OPTIONS",
|
|
},
|
|
AllowedHeaders: []string{
|
|
"Accept", "Authorization",
|
|
"Content-Type", "X-CSRF-Token",
|
|
},
|
|
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
},
|
|
},
|
|
)
|
|
}
|