Files
descrybe/apps/api/internal/httpapi/security_middleware.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

93 lines
2.6 KiB
Go

package httpapi
import (
"net"
"net/http"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
)
// TrustedRealIP rewrites RemoteAddr from client IP headers only when the
// immediate peer is listed in TRUSTED_PROXIES. Empty allowlist leaves
// RemoteAddr unchanged (ignores spoofable X-Forwarded-For / X-Real-IP).
func TrustedRealIP(trusted []string) func(http.Handler) http.Handler {
nets, err := config.ParseTrustedProxyNets(trusted)
if err != nil || len(nets) == 0 {
return func(next http.Handler) http.Handler { return next }
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isTrustedPeer(r.RemoteAddr, nets) {
if rip := clientIPFromProxyHeaders(r); rip != "" {
r.RemoteAddr = rip
}
}
next.ServeHTTP(w, r)
})
}
}
// apiContentSecurityPolicy is a strict CSP for JSON API responses (no HTML/scripts).
const apiContentSecurityPolicy = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
// SecurityHeaders sets baseline API response headers. HSTS is only emitted
// when session cookies are marked Secure (HTTPS deployments).
func SecurityHeaders(sessionSecure bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
h.Set("Content-Security-Policy", apiContentSecurityPolicy)
if sessionSecure {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
}
func isTrustedPeer(remoteAddr string, nets []*net.IPNet) bool {
ip := peerIP(remoteAddr)
if ip == nil {
return false
}
for _, n := range nets {
if n.Contains(ip) {
return true
}
}
return false
}
func peerIP(remoteAddr string) net.IP {
host := strings.TrimSpace(remoteAddr)
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
return net.ParseIP(host)
}
func clientIPFromProxyHeaders(r *http.Request) string {
var ip string
if tcip := r.Header.Get("True-Client-IP"); tcip != "" {
ip = tcip
} else if xrip := r.Header.Get("X-Real-IP"); xrip != "" {
ip = xrip
} else if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
i := strings.Index(xff, ",")
if i == -1 {
i = len(xff)
}
ip = xff[:i]
}
ip = strings.TrimSpace(ip)
if ip == "" || net.ParseIP(ip) == nil {
return ""
}
return ip
}