mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:31:36 +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"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -1596,6 +1597,22 @@ type AuthConfig struct {
|
|||||||
// legacy `api-key` auth type ignore this struct entirely.
|
// legacy `api-key` auth type ignore this struct entirely.
|
||||||
Session SessionConfig
|
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
|
// OIDCBCLMaxAgeSeconds is the iat-freshness skew window for OIDC
|
||||||
// back-channel-logout tokens. logout_tokens with iat outside the
|
// back-channel-logout tokens. logout_tokens with iat outside the
|
||||||
// window are rejected with audit outcome=iat_stale (in the past)
|
// window are rejected with audit outcome=iat_stale (in the past)
|
||||||
@@ -1849,6 +1866,9 @@ func Load() (*Config, error) {
|
|||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"),
|
Type: getEnv("CERTCTL_AUTH_TYPE", "api-key"),
|
||||||
Secret: getEnv("CERTCTL_AUTH_SECRET", ""),
|
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()
|
// NamedKeys is populated from CERTCTL_API_KEYS_NAMED below so Load()
|
||||||
// can surface parse errors alongside other config errors.
|
// 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)
|
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
|
// Validate keygen mode
|
||||||
validKeygenModes := map[string]bool{
|
validKeygenModes := map[string]bool{
|
||||||
"agent": true,
|
"agent": true,
|
||||||
@@ -3033,3 +3083,44 @@ func isValidKeyName(s string) bool {
|
|||||||
}
|
}
|
||||||
return true
|
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) {
|
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{
|
cfg := &Config{
|
||||||
Server: validServerConfig(t),
|
Server: srv,
|
||||||
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25},
|
||||||
Log: LogConfig{Level: "info", Format: "json"},
|
Log: LogConfig{Level: "info", Format: "json"},
|
||||||
Auth: AuthConfig{Type: "none", Secret: ""},
|
Auth: AuthConfig{Type: "none", Secret: ""},
|
||||||
@@ -442,7 +448,117 @@ func TestValidate_AuthTypeNone(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := cfg.Validate(); err != nil {
|
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