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

@@ -9,8 +9,10 @@ package domains
import (
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
@@ -161,6 +163,86 @@ func (m *Manager) domainPath(domain string) string {
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.
func (m *Manager) Register(dom Domain) error {
m.mu.Lock()
@@ -169,6 +251,9 @@ func (m *Manager) Register(dom Domain) error {
if dom.DomainName == "" {
return fmt.Errorf("domain is required")
}
if !isValidDomain(dom.DomainName) {
return fmt.Errorf("invalid domain name: %q", dom.DomainName)
}
hasBackend := dom.Backend.Address != ""
hasRoutes := len(dom.Routes) > 0
@@ -185,14 +270,28 @@ func (m *Manager) Register(dom Domain) error {
if r.Backend.Address == "" {
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 == "" {
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 {
if dom.Backend.Type == "" {
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