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
This commit is contained in:
2026-07-14 12:38:17 +00:00
parent 4500a1a45e
commit 60f3ca4a3a
8 changed files with 149 additions and 16 deletions

View File

@@ -406,11 +406,7 @@ func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
return return
} }
showRaw := r.URL.Query().Get("raw") == "true"
if !showRaw {
redactSecrets(secretsMap) redactSecrets(secretsMap)
}
respondJSON(w, http.StatusOK, secretsMap) respondJSON(w, http.StatusOK, secretsMap)
} }

View File

@@ -138,7 +138,7 @@ func TestGetGlobalSecrets_RedactsLeafValues(t *testing.T) {
} }
} }
func TestGetGlobalSecrets_RawShowsValues(t *testing.T) { func TestGetGlobalSecrets_AlwaysRedacted(t *testing.T) {
api, tmpDir := setupTestAPI(t) api, tmpDir := setupTestAPI(t)
secrets := map[string]any{ secrets := map[string]any{
@@ -149,6 +149,7 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
data, _ := yaml.Marshal(secrets) data, _ := yaml.Marshal(secrets)
os.WriteFile(filepath.Join(tmpDir, "secrets.yaml"), data, 0600) os.WriteFile(filepath.Join(tmpDir, "secrets.yaml"), data, 0600)
// Even with ?raw=true, secrets must be redacted (bypass removed for security)
req := httptest.NewRequest("GET", "/api/v1/secrets?raw=true", nil) req := httptest.NewRequest("GET", "/api/v1/secrets?raw=true", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@@ -158,8 +159,8 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
json.Unmarshal(w.Body.Bytes(), &resp) json.Unmarshal(w.Body.Bytes(), &resp)
cf := resp["cloudflare"].(map[string]any) cf := resp["cloudflare"].(map[string]any)
if cf["apiToken"] != "my-token" { if cf["apiToken"] == "my-token" {
t.Errorf("expected raw apiToken, got %v", cf["apiToken"]) t.Error("expected apiToken to be redacted, but got raw value")
} }
} }

View File

@@ -49,6 +49,11 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler {
return 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() start := time.Now()
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK} sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r) next.ServeHTTP(sw, r)

View File

@@ -61,6 +61,13 @@ func (m *Manager) Provision(domain, email string) error {
if email == "" { if email == "" {
return fmt.Errorf("email is required") return fmt.Errorf("email is required")
} }
// Defense-in-depth: reject domains with shell metacharacters.
// Domain validation at the API boundary should already prevent this.
for _, c := range domain {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '*') {
return fmt.Errorf("invalid domain for cert provisioning: %q", domain)
}
}
if _, err := os.Stat(m.credsPath); err != nil { if _, err := os.Stat(m.credsPath); err != nil {
return fmt.Errorf("cloudflare credentials not found at %s; configure the Cloudflare API token first", m.credsPath) return fmt.Errorf("cloudflare credentials not found at %s; configure the Cloudflare API token first", m.credsPath)
} }

View File

@@ -9,8 +9,10 @@ package domains
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
@@ -161,6 +163,86 @@ func (m *Manager) domainPath(domain string) string {
return filepath.Join(m.domainsDir(), domainToFilename(domain)) return filepath.Join(m.domainsDir(), domainToFilename(domain))
} }
// isValidDomain checks that a string is a valid FQDN (RFC 1123) or wildcard domain.
// Prevents config injection via newlines, spaces, or shell metacharacters.
func isValidDomain(s string) bool {
if len(s) == 0 || len(s) > 253 {
return false
}
for _, label := range strings.Split(s, ".") {
if len(label) == 0 || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for _, c := range label {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '*') {
return false
}
}
}
return true
}
// isValidBackendAddress checks that an address is a valid host:port.
func isValidBackendAddress(addr string) bool {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return false
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return false
}
if net.ParseIP(host) != nil {
return true
}
return isValidDomain(host)
}
// isValidHeaderName checks that a header name contains only HTTP token characters.
func isValidHeaderName(s string) bool {
if len(s) == 0 {
return false
}
for _, c := range s {
if c <= ' ' || c == ':' || c == '"' || c >= 0x7f {
return false
}
}
return true
}
// isValidHeaderValue checks that a header value contains no newlines or NULs.
func isValidHeaderValue(s string) bool {
return !strings.ContainsAny(s, "\r\n\x00")
}
// validateHeaders checks all header keys and values in a HeaderConfig.
func validateHeaders(h *HeaderConfig) error {
if h == nil {
return nil
}
for k, v := range h.Request {
if !isValidHeaderName(k) {
return fmt.Errorf("invalid request header name: %q", k)
}
if !isValidHeaderValue(v) {
return fmt.Errorf("invalid request header value for %q", k)
}
}
for k, v := range h.Response {
if !isValidHeaderName(k) {
return fmt.Errorf("invalid response header name: %q", k)
}
if !isValidHeaderValue(v) {
return fmt.Errorf("invalid response header value for %q", k)
}
}
return nil
}
// Register creates or updates a domain registration. // Register creates or updates a domain registration.
func (m *Manager) Register(dom Domain) error { func (m *Manager) Register(dom Domain) error {
m.mu.Lock() m.mu.Lock()
@@ -169,6 +251,9 @@ func (m *Manager) Register(dom Domain) error {
if dom.DomainName == "" { if dom.DomainName == "" {
return fmt.Errorf("domain is required") return fmt.Errorf("domain is required")
} }
if !isValidDomain(dom.DomainName) {
return fmt.Errorf("invalid domain name: %q", dom.DomainName)
}
hasBackend := dom.Backend.Address != "" hasBackend := dom.Backend.Address != ""
hasRoutes := len(dom.Routes) > 0 hasRoutes := len(dom.Routes) > 0
@@ -185,14 +270,28 @@ func (m *Manager) Register(dom Domain) error {
if r.Backend.Address == "" { if r.Backend.Address == "" {
return fmt.Errorf("route %d: backend address is required", i) return fmt.Errorf("route %d: backend address is required", i)
} }
if !isValidBackendAddress(r.Backend.Address) {
return fmt.Errorf("route %d: invalid backend address: %q", i, r.Backend.Address)
}
if r.Backend.Type == "" { if r.Backend.Type == "" {
return fmt.Errorf("route %d: backend type is required", i) return fmt.Errorf("route %d: backend type is required", i)
} }
if err := validateHeaders(r.Headers); err != nil {
return fmt.Errorf("route %d: %w", i, err)
}
} }
} else { } else {
if dom.Backend.Type == "" { if dom.Backend.Type == "" {
return fmt.Errorf("backend type is required") return fmt.Errorf("backend type is required")
} }
// dns-only backends may omit port (just an IP for resolution)
if dom.Backend.Type == BackendDNSOnly {
if net.ParseIP(dom.Backend.Address) == nil && !isValidBackendAddress(dom.Backend.Address) {
return fmt.Errorf("invalid backend address: %q", dom.Backend.Address)
}
} else if !isValidBackendAddress(dom.Backend.Address) {
return fmt.Errorf("invalid backend address: %q", dom.Backend.Address)
}
} }
// Default TLS mode based on backend type // Default TLS mode based on backend type

View File

@@ -39,6 +39,7 @@ type Server struct {
type Config struct { type Config struct {
Port int // listen port (default 4222) Port int // listen port (default 4222)
DataDir string // JetStream storage directory DataDir string // JetStream storage directory
AuthToken string // require this token for client connections (empty = no auth)
} }
// Start launches the embedded NATS server with JetStream and creates // Start launches the embedded NATS server with JetStream and creates
@@ -52,10 +53,12 @@ func Start(cfg Config) (*Server, error) {
Port: cfg.Port, Port: cfg.Port,
JetStream: true, JetStream: true,
StoreDir: cfg.DataDir, StoreDir: cfg.DataDir,
// Quiet logging — NATS logs at its own level
NoLog: true, NoLog: true,
NoSigs: true, NoSigs: true,
} }
if cfg.AuthToken != "" {
opts.Authorization = cfg.AuthToken
}
ns, err := natsserver.NewServer(opts) ns, err := natsserver.NewServer(opts)
if err != nil { if err != nil {
@@ -87,7 +90,11 @@ func Start(cfg Config) (*Server, error) {
slog.Info("NATS JetStream server started", "port", cfg.Port, "dataDir", cfg.DataDir) slog.Info("NATS JetStream server started", "port", cfg.Port, "dataDir", cfg.DataDir)
// Connect as internal client // Connect as internal client
nc, err := nats.Connect(ns.ClientURL()) connectOpts := []nats.Option{}
if cfg.AuthToken != "" {
connectOpts = append(connectOpts, nats.Token(cfg.AuthToken))
}
nc, err := nats.Connect(ns.ClientURL(), connectOpts...)
if err != nil { if err != nil {
ns.Shutdown() ns.Shutdown()
return nil, fmt.Errorf("connecting to embedded NATS: %w", err) return nil, fmt.Errorf("connecting to embedded NATS: %w", err)

View File

@@ -229,6 +229,13 @@ func (m *Manager) readPeerFile(path string) (*Peer, error) {
// AddPeer generates a new peer keypair, assigns the next available IP in the VPN // AddPeer generates a new peer keypair, assigns the next available IP in the VPN
// subnet, saves the peer, and returns it. // subnet, saves the peer, and returns it.
func (m *Manager) AddPeer(name string) (*Peer, error) { func (m *Manager) AddPeer(name string) (*Peer, error) {
// Validate peer name — prevents config injection via wg0.conf comments
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == ' ') {
return nil, fmt.Errorf("invalid peer name: only letters, digits, hyphens, underscores, and spaces allowed")
}
}
cfg, err := m.GetConfig() cfg, err := m.GetConfig()
if err != nil { if err != nil {
return nil, err return nil, err

11
main.go
View File

@@ -19,6 +19,7 @@ import (
"github.com/wild-cloud/wild-central/internal/frontend" "github.com/wild-cloud/wild-central/internal/frontend"
"github.com/wild-cloud/wild-central/internal/logging" "github.com/wild-cloud/wild-central/internal/logging"
"github.com/wild-cloud/wild-central/internal/natsbus" "github.com/wild-cloud/wild-central/internal/natsbus"
"github.com/wild-cloud/wild-central/internal/secrets"
) )
var startTime time.Time var startTime time.Time
@@ -99,9 +100,19 @@ func main() {
fmt.Sscanf(v, "%d", &natsPort) fmt.Sscanf(v, "%d", &natsPort)
} }
// Load or generate NATS auth token from secrets
secretsMgr := secrets.NewManager(filepath.Join(dataDir, "secrets.yaml"))
natsToken, _ := secretsMgr.GetSecret("nats.authToken")
if natsToken == "" {
natsToken, _ = secrets.GenerateSecret(32)
_ = secretsMgr.SetSecret("nats.authToken", natsToken)
slog.Info("generated NATS auth token", "component", "startup")
}
natsSrv, err := natsbus.Start(natsbus.Config{ natsSrv, err := natsbus.Start(natsbus.Config{
Port: natsPort, Port: natsPort,
DataDir: natsDataDir, DataDir: natsDataDir,
AuthToken: natsToken,
}) })
if err != nil { if err != nil {
slog.Error("failed to start NATS server", "error", err) slog.Error("failed to start NATS server", "error", err)