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
+91
View File
@@ -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
}
+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,
}
}