feat(backend): server hardening: timeouts, security headers, trusted-proxy client IP (closes #19)
check / check (push) Failing after 1s
check / check (push) Failing after 1s
Add ReadHeaderTimeout and IdleTimeout to the http.Server as named constants beside the existing timeouts. Add a SecurityHeaders middleware (HSTS, a JSON-API CSP of default-src 'none'; frame-ancestors 'none', X-Frame-Options DENY, nosniff, Referrer-Policy, Permissions-Policy), registered before CORS so preflight responses carry it. Resolve the client IP from X-Forwarded-For / X-Real-IP only when the direct peer is in the trusted-proxy allowlist (loopback plus RFC1918 by default, configurable via TRUSTED_PROXIES); an untrusted peer's forwarded headers are ignored. Uses net/netip; no new dependency. Model: opus-4-8 (implementation and review); claude-fable-5 (merge)
This commit was merged in pull request #56.
This commit is contained in:
@@ -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: ¶ms,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user