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
}
showRaw := r.URL.Query().Get("raw") == "true"
if !showRaw {
redactSecrets(secretsMap)
}
redactSecrets(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)
secrets := map[string]any{
@@ -149,6 +149,7 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
data, _ := yaml.Marshal(secrets)
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)
w := httptest.NewRecorder()
@@ -158,8 +159,8 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) {
json.Unmarshal(w.Body.Bytes(), &resp)
cf := resp["cloudflare"].(map[string]any)
if cf["apiToken"] != "my-token" {
t.Errorf("expected raw apiToken, got %v", cf["apiToken"])
if cf["apiToken"] == "my-token" {
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
}
// 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)