mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:11:30 +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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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