diff --git a/internal/config/config.go b/internal/config/config.go index 0bf13fd..0047c09 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "crypto/tls" "fmt" "log/slog" + "net" "os" "strconv" "strings" @@ -1596,6 +1597,22 @@ type AuthConfig struct { // legacy `api-key` auth type ignore this struct entirely. Session SessionConfig + // DemoModeAck must be true to allow CERTCTL_AUTH_TYPE=none with a + // non-loopback listen address. Default false. Audit 2026-05-10 + // HIGH-12 closure: pre-fix, an operator who flipped Type=none + // "temporarily" or via misconfig exposed admin functions to anyone + // reachable on port 8443 — the demo-mode synthetic actor + // `actor-demo-anon` is wired with `AdminKey=true`, so every + // request was served as a full admin. The control plane is + // HTTPS-only but a misconfigured ingress / public bind meant + // unauthenticated full admin. Post-fix: Validate() refuses to + // start when Type=none AND the listener binds to a non-loopback + // address (0.0.0.0, ::, or a routable IP) UNLESS the operator + // also sets DemoModeAck=true to acknowledge the bypass. Production + // deployments MUST set Type to a real authn type (api-key | oidc). + // Setting: CERTCTL_DEMO_MODE_ACK environment variable. + DemoModeAck bool + // OIDCBCLMaxAgeSeconds is the iat-freshness skew window for OIDC // back-channel-logout tokens. logout_tokens with iat outside the // window are rejected with audit outcome=iat_stale (in the past) @@ -1849,6 +1866,9 @@ func Load() (*Config, error) { Auth: AuthConfig{ Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"), Secret: getEnv("CERTCTL_AUTH_SECRET", ""), + // Audit 2026-05-10 HIGH-12 closure: required-true to allow + // CERTCTL_AUTH_TYPE=none with a non-loopback listen address. + DemoModeAck: getEnvBool("CERTCTL_DEMO_MODE_ACK", false), // NamedKeys is populated from CERTCTL_API_KEYS_NAMED below so Load() // can surface parse errors alongside other config errors. @@ -2526,6 +2546,36 @@ func (c *Config) Validate() error { return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type) } + // Audit 2026-05-10 HIGH-12 closure: refuse to start when + // CERTCTL_AUTH_TYPE=none is bound to a non-loopback address unless + // the operator explicitly acknowledges the bypass via + // CERTCTL_DEMO_MODE_ACK=true. + // + // Rationale: demo mode wires the synthetic actor `actor-demo-anon` + // with `AdminKey=true` on every request. The control plane is + // HTTPS-only, but a misconfigured ingress / public listen-bind + // means any reachable client gets full admin without authentication. + // The fail-closed guard converts what was a documentation-only + // warning into a hard runtime check operators cannot ignore. + // + // Localhost / loopback (127.0.0.1, ::1, "localhost") is exempt + // because the demo `docker compose up` flow legitimately serves + // the dashboard to the operator's own browser; binding to + // 0.0.0.0 / :: / a routable IP is what surfaces the admin to the + // network and triggers the guard. + if c.Auth.Type == string(AuthTypeNone) { + if !isLoopbackAddr(c.Server.Host) && !c.Auth.DemoModeAck { + return fmt.Errorf( + "CERTCTL_AUTH_TYPE=none with non-loopback CERTCTL_SERVER_HOST=%q "+ + "requires CERTCTL_DEMO_MODE_ACK=true to acknowledge that every "+ + "request will be served as the synthetic admin actor `actor-demo-anon`. "+ + "This is INSECURE — operators must explicitly opt in. Production "+ + "deployments MUST set CERTCTL_AUTH_TYPE to a real authn type "+ + "(api-key | oidc); see docs/operator/security.md for guidance.", + c.Server.Host) + } + } + // Validate keygen mode validKeygenModes := map[string]bool{ "agent": true, @@ -3033,3 +3083,44 @@ func isValidKeyName(s string) bool { } return true } + +// isLoopbackAddr returns true when host is bound to a loopback +// interface only (127.0.0.1, ::1, or "localhost"). Used by the +// HIGH-12 demo-mode startup guard to refuse non-loopback binds when +// CERTCTL_AUTH_TYPE=none is in effect. +// +// "" (unset) AND "0.0.0.0" / "::" / "[::]" return false because those +// surface the listener to every interface — exactly the misconfiguration +// the guard is designed to catch. +// +// Hostnames other than "localhost" return false defensively: a hostname +// could resolve to a non-loopback IP at runtime; we don't perform DNS +// here because the guard runs at startup before any network state is +// available, and we don't want a misconfigured /etc/hosts to silently +// pass the guard. Operators wanting to bind to a non-default loopback +// alias must either use 127.0.0.1 / ::1 directly or set +// CERTCTL_DEMO_MODE_ACK=true. +func isLoopbackAddr(host string) bool { + switch host { + case "": + // Empty / unset host — Go's net/http.Server treats this as + // "all interfaces" (equivalent to 0.0.0.0). Surface it to the + // network → not loopback. + return false + case "0.0.0.0", "::", "[::]": + return false + case "localhost": + return true + } + // Strip a trailing :port if the operator passed a host:port pair + // rather than a bare host (defensive — Server.Host is documented + // as host-only, but be lenient). + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + // Hostname that isn't "localhost" — fail closed. + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5213596..973ffca 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -423,8 +423,14 @@ func TestValidate_ValidConfig(t *testing.T) { } func TestValidate_AuthTypeNone(t *testing.T) { + srv := validServerConfig(t) + // Audit 2026-05-10 HIGH-12: Type=none with non-loopback host now + // fails closed unless DemoModeAck=true. Bind the unit-test config + // to 127.0.0.1 so the legitimate "demo on loopback" path stays + // green (the existing test predates the HIGH-12 guard). + srv.Host = "127.0.0.1" cfg := &Config{ - Server: validServerConfig(t), + Server: srv, Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25}, Log: LogConfig{Level: "info", Format: "json"}, Auth: AuthConfig{Type: "none", Secret: ""}, @@ -442,7 +448,117 @@ func TestValidate_AuthTypeNone(t *testing.T) { }, } if err := cfg.Validate(); err != nil { - t.Errorf("Validate() returned error for auth type 'none': %v", err) + t.Errorf("Validate() returned error for auth type 'none' on loopback: %v", err) + } +} + +// Audit 2026-05-10 HIGH-12 closure — pin the demo-mode listen-address +// guard. Pre-fix, an operator who flipped CERTCTL_AUTH_TYPE=none on a +// non-loopback bind exposed admin functions to anyone reachable on +// port 8443 (the synthetic actor `actor-demo-anon` is wired with +// AdminKey=true). Post-fix, Validate() refuses to start unless +// CERTCTL_DEMO_MODE_ACK=true acknowledges the bypass. +func TestValidate_AuthTypeNone_NonLoopback_FailsClosed(t *testing.T) { + srv := validServerConfig(t) + srv.Host = "0.0.0.0" + cfg := &Config{ + Server: srv, + Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25}, + Log: LogConfig{Level: "info", Format: "json"}, + Auth: AuthConfig{Type: "none", Secret: ""}, + Keygen: KeygenConfig{Mode: "agent"}, + Scheduler: validSchedulerConfig(), + } + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() returned nil; want HIGH-12 demo-mode guard to fail closed on Host=0.0.0.0 with Type=none and DemoModeAck=false") + } + if !strings.Contains(err.Error(), "CERTCTL_DEMO_MODE_ACK=true") { + t.Errorf("Validate() error = %q; want it to mention CERTCTL_DEMO_MODE_ACK=true", err.Error()) + } +} + +func TestValidate_AuthTypeNone_NonLoopback_AckPasses(t *testing.T) { + srv := validServerConfig(t) + srv.Host = "0.0.0.0" + cfg := &Config{ + Server: srv, + Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25}, + Log: LogConfig{Level: "info", Format: "json"}, + Auth: AuthConfig{Type: "none", Secret: "", DemoModeAck: true}, + Keygen: KeygenConfig{Mode: "agent"}, + Scheduler: validSchedulerConfig(), + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() with DemoModeAck=true returned error: %v", err) + } +} + +func TestValidate_AuthTypeAPIKey_NonLoopback_NotAffected(t *testing.T) { + // Real authn types are unaffected by the HIGH-12 guard — it only + // fires when Type=none. + srv := validServerConfig(t) + srv.Host = "0.0.0.0" + cfg := &Config{ + Server: srv, + Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25}, + Log: LogConfig{Level: "info", Format: "json"}, + Auth: AuthConfig{Type: "api-key", Secret: "real-secret"}, + Keygen: KeygenConfig{Mode: "agent"}, + Scheduler: validSchedulerConfig(), + } + if err := cfg.Validate(); err != nil { + t.Errorf("Validate() with Type=api-key on 0.0.0.0 returned error: %v", err) + } +} + +func TestIsLoopbackAddr(t *testing.T) { + cases := []struct { + host string + want bool + }{ + // Loopback positives. + {"127.0.0.1", true}, + {"::1", true}, + {"localhost", true}, + {"127.0.0.5", true}, // any 127.0.0.0/8 + // Non-loopback negatives — the cases the HIGH-12 guard catches. + {"", false}, + {"0.0.0.0", false}, + {"::", false}, + {"[::]", false}, + {"10.0.0.1", false}, + {"192.168.1.1", false}, + {"203.0.113.42", false}, + {"example.com", false}, // hostname → fail closed + {"my-cert-server.internal", false}, + // Defensive: host:port form should still classify the host part. + {"127.0.0.1:8443", true}, + {"0.0.0.0:8443", false}, + } + for _, tc := range cases { + got := isLoopbackAddr(tc.host) + if got != tc.want { + t.Errorf("isLoopbackAddr(%q) = %v; want %v", tc.host, got, tc.want) + } + } +} + +// validSchedulerConfig returns a SchedulerConfig with all required +// fields set so Validate() doesn't fail for unrelated reasons in the +// HIGH-12 test cases. Mirrors the inline initialization in the +// pre-existing TestValidate_* tests. +func validSchedulerConfig() SchedulerConfig { + return SchedulerConfig{ + RenewalCheckInterval: 1 * time.Hour, + JobProcessorInterval: 30 * time.Second, + AgentHealthCheckInterval: 2 * time.Minute, + NotificationProcessInterval: 1 * time.Minute, + NotificationRetryInterval: 2 * time.Minute, + RetryInterval: 5 * time.Minute, + JobTimeoutInterval: 10 * time.Minute, + AwaitingCSRTimeout: 24 * time.Hour, + AwaitingApprovalTimeout: 168 * time.Hour, } }