feat(backend): server hardening — timeouts, security headers, trusted-proxy client IP (closes #19)
check / check (push) Failing after 0s

Add ReadHeaderTimeout and IdleTimeout to the http.Server (named
constants beside the existing timeouts) to close the slowloris and
idle-keep-alive gaps.

Add a SecurityHeaders middleware setting HSTS, a JSON-API CSP
(default-src 'none'; frame-ancestors 'none'), X-Frame-Options: DENY,
X-Content-Type-Options: nosniff, Referrer-Policy, and Permissions-Policy.
It is registered before CORS so the headers ride on preflight responses.

Resolve the client IP from X-Forwarded-For / X-Real-IP only when the
direct peer is in a trusted-proxy allowlist, defaulting to loopback plus
RFC1918 and configurable via TRUSTED_PROXIES; an untrusted peer's
forwarded headers are ignored and the direct peer is logged. Uses
net/netip; no new dependency.

Model: opus-4-8
This commit is contained in:
2026-09-21 12:49:58 +00:00
parent f7c7f92e27
commit 911dfbb825
8 changed files with 347 additions and 16 deletions
+28
View File
@@ -5,6 +5,7 @@ package config
import (
"errors"
"log/slog"
"strings"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
@@ -14,6 +15,14 @@ import (
"go.uber.org/fx"
)
// defaultTrustedProxies lists the networks whose forwarded
// headers are honoured by default. It covers the RFC1918
// ranges (to match nginx.conf) plus IPv4 and IPv6 loopback,
// because the reverse proxy shares the container and reaches
// the backend over loopback.
const defaultTrustedProxies = "127.0.0.1/32,::1/128," +
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// Params defines the dependencies for Config.
type Params struct {
fx.In
@@ -30,6 +39,7 @@ type Config struct {
MetricsUsername string
Port int
SentryDSN string
TrustedProxies []string
log *slog.Logger
params *Params
}
@@ -56,6 +66,7 @@ func New(
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
viper.SetDefault("TRUSTED_PROXIES", defaultTrustedProxies)
err := viper.ReadInConfig()
if err != nil {
@@ -73,6 +84,7 @@ func New(
MetricsUsername: viper.GetString("METRICS_USERNAME"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
log: log,
params: &params,
}
@@ -84,3 +96,19 @@ func New(
return s, nil
}
// splitList turns a comma-separated setting into a trimmed
// slice, dropping empty entries.
func splitList(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}