Files
wild-central/internal/api/v1/middleware.go
Paul Payne 60f3ca4a3a Security hardening: input validation, secrets protection, NATS auth
Config injection prevention:
- Add FQDN validation for domain names (RFC 1123) in Register/Update —
  rejects newlines, spaces, shell metacharacters that could inject into
  HAProxy/dnsmasq configs
- Add backend address validation (valid host:port format, valid IP or
  hostname, port 1-65535). DNS-only backends allow bare IPs.
- Add header key/value validation — keys must be HTTP token chars only,
  values must not contain newlines or NULs
- Add WireGuard peer name validation (alphanumeric + hyphens + underscores)
- Add defense-in-depth domain validation in certbot Provision()

Secrets protection:
- Remove ?raw=true bypass on GET /api/v1/secrets — secrets are now always
  redacted in API responses regardless of query parameters
- Update test to verify redaction cannot be bypassed

NATS authentication:
- Generate random auth token on first startup, store in secrets.yaml
- Pass token to embedded NATS server via Authorization option
- Internal client connects with the same token
- External NATS clients (Wild Cloud) must now authenticate

Security headers:
- Add X-Content-Type-Options: nosniff
- Add X-Frame-Options: DENY
- Add Cache-Control: no-store
2026-07-14 12:38:17 +00:00

81 lines
2.0 KiB
Go

package v1
import (
"bufio"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
)
// statusResponseWriter wraps http.ResponseWriter to capture the status code.
// Implements http.Hijacker so WebSocket upgrades work through the proxy.
type statusResponseWriter struct {
http.ResponseWriter
status int
}
func (w *statusResponseWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := w.ResponseWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, fmt.Errorf("upstream ResponseWriter does not implement http.Hijacker")
}
func (w *statusResponseWriter) Flush() {
if fl, ok := w.ResponseWriter.(http.Flusher); ok {
fl.Flush()
}
}
// RequestLoggingMiddleware logs method, path, status, and duration for each request.
// Long-lived connections (SSE, WebSocket) are excluded.
func RequestLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Skip SSE and WebSocket endpoints (long-lived connections)
if strings.HasSuffix(path, "/events") || strings.HasSuffix(path, "/ws") || strings.HasSuffix(path, "/stream") {
next.ServeHTTP(w, r)
return
}
// Security response headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Cache-Control", "no-store")
start := time.Now()
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
attrs := []any{
"status", sw.status,
"method", r.Method,
"path", path,
"duration", time.Since(start),
}
// Add route params if present
vars := mux.Vars(r)
if domain := vars["domain"]; domain != "" {
attrs = append(attrs, "domain", domain)
}
if sw.status >= 400 {
slog.Error("request", attrs...)
} else {
slog.Info("request", attrs...)
}
})
}