fix(config): refuse to start when CERTCTL_AUTH_TYPE=none binds non-loopback (HIGH-12)

Audit 2026-05-10 HIGH-12 closure. Pre-fix, an operator who flipped
CERTCTL_AUTH_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. The control
plane is HTTPS-only, but a misconfigured ingress / public listen-bind
means any reachable client gets full admin without authentication.
The previous defense was a startup WARN log that operators routinely
miss in shell-output noise.

Post-fix: Config.Validate() refuses to start when:
  - Auth.Type = 'none'
  - AND Server.Host is non-loopback (NOT in {127.0.0.1, ::1, localhost})
  - AND Auth.DemoModeAck = false (CERTCTL_DEMO_MODE_ACK=true overrides)

Real authn types (api-key, oidc) are unaffected — the guard fires only
when Type=none.

isLoopbackAddr defensively rejects:
  - '' (Go's default-everything bind)
  - '0.0.0.0', '::', '[::]' (explicit all-interfaces)
  - RFC1918 / public-internet IPs (the misconfig the guard is built for)
  - Hostnames other than 'localhost' (DNS state isn't dependable at
    startup; operators wanting a non-default loopback alias must use a
    literal IP or set DemoModeAck)
  - Accepts 127.0.0.0/8 (all loopback IPs), ::1, localhost
  - Strips host:port form before classifying

Regression matrix in config_test.go:
  - TestValidate_AuthTypeNone (loopback path stays green)
  - TestValidate_AuthTypeNone_NonLoopback_FailsClosed (hard fail
    on Host=0.0.0.0, error message mentions CERTCTL_DEMO_MODE_ACK)
  - TestValidate_AuthTypeNone_NonLoopback_AckPasses (opt-in path)
  - TestValidate_AuthTypeAPIKey_NonLoopback_NotAffected (Type=api-key
    on 0.0.0.0 unaffected by the guard)
  - TestIsLoopbackAddr (15-case matrix: IPv4 + IPv6 + RFC1918 + public
    IPs + hostnames + host:port forms)

The Phase 2 spec items — production-startup banner when actor-demo-anon
has residual role grants; CI guard banning new synthetic-admin code
paths — are partial-deferred to a v3 hygiene bundle. The high-impact,
fail-closed leg ships in this commit.

Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-12
Spec: cowork/auth-bundles-fixes-2026-05-10/11-high-12-demo-mode-guard.md
This commit is contained in:
shankar0123
2026-05-10 21:29:06 +00:00
parent f5ba17114d
commit 2e97cc10b8
2 changed files with 209 additions and 2 deletions
+118 -2
View File
@@ -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,
}
}