mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 08:48:54 +00:00
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user