diff --git a/cmd/server/main.go b/cmd/server/main.go index 41d6739..bfaeb82 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,6 +48,7 @@ import ( "github.com/certctl-io/certctl/internal/service" authsvc "github.com/certctl-io/certctl/internal/service/auth" "github.com/certctl-io/certctl/internal/trustanchor" + "github.com/certctl-io/certctl/internal/validation" ) func main() { @@ -124,19 +125,39 @@ func main() { logger.Warn("⚠ DEMO MODE ACTIVE — CERTCTL_DEMO_MODE_ACK=true is set; every request is served as the synthetic admin actor `actor-demo-anon` (no authentication enforced). This deployment MUST NOT hold production keys, certificates, or audit history. To promote to production: (1) unset CERTCTL_DEMO_MODE_ACK; (2) set CERTCTL_AUTH_TYPE=api-key or oidc; (3) set CERTCTL_AUTH_SECRET to a fresh `openssl rand -base64 32`; (4) set CERTCTL_KEYGEN_MODE=agent; (5) rotate CERTCTL_CONFIG_ENCRYPTION_KEY to a fresh `openssl rand -base64 32` (≥ 32 bytes, not the change-me placeholder); (6) restart the server. See docs/operator/security.md for the full posture.") } - // Bundle-5 / Audit H-007: deprecation WARN when the agent bootstrap - // token is unset. Pre-Bundle-5 there was no token at all; the v2.0.x - // default keeps the warn-mode pass-through so existing demo deploys - // keep working, but operators must set CERTCTL_AGENT_BOOTSTRAP_TOKEN - // before v2.2.0 lands. This is a one-shot startup line — the - // per-request path stays silent so a busy registration endpoint - // doesn't flood the log. + // Bundle-5 / Audit H-007 + acquisition-audit RED-003 closure + // (Sprint 5 ACQ, 2026-05-16): deny-empty default for the agent + // bootstrap token. v2.2.0 flipped CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY + // from false → true; Validate() now refuses to start with an + // empty token UNLESS the operator either (a) explicitly opts back + // into v2.1.x warn-mode with CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false + // or (b) is running a demo deploy (CERTCTL_DEMO_MODE_ACK=true). + // + // The remaining code path here only fires in those two override + // scenarios — in both cases the operator has accepted the + // posture, but a one-shot startup line keeps the warn-mode case + // visible in journals. if cfg.Auth.AgentBootstrapToken == "" { - logger.Warn("agent bootstrap token unset (CERTCTL_AGENT_BOOTSTRAP_TOKEN) — agents may self-register without authentication; this default will become deny-by-default in v2.2.0; generate one with: openssl rand -hex 32") + logger.Warn("agent bootstrap token unset (CERTCTL_AGENT_BOOTSTRAP_TOKEN) — agents may self-register without authentication; running in v2.1.x-compat warn-mode (DENY_EMPTY=false) or demo mode (DEMO_MODE_ACK=true). Production deploys MUST set the token; generate with: openssl rand -base64 32") } else { logger.Info("agent bootstrap token configured (length redacted; constant-time compare on POST /api/v1/agents)") } + // Acquisition-audit SEC-009 + RED-005 closure (Sprint 5 ACQ, + // 2026-05-16). Opt-in RFC1918 outbound block for hosted-IaaS + // operators where private-IP space carries internal trust + // (Kubernetes API on 10.96.0.1 in default kubeadm clusters, + // cloud-provider monitoring endpoints, etc.). The toggle wires + // into the package-level state in internal/validation/ssrf.go; + // from there every IsReservedIP-derived path (SafeHTTPDialContext, + // ValidateSafeURL, the network scanner, the webhook + OIDC + ACME + // callers) picks up the policy transitively. Default false + // preserves the existing self-hosted threat model. + validation.SetBlockRFC1918Outbound(cfg.Network.BlockRFC1918Outbound) + if cfg.Network.BlockRFC1918Outbound { + logger.Info("RFC1918 outbound block ENABLED (CERTCTL_BLOCK_RFC1918_OUTBOUND=true) — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 are reserved for outbound HTTP egress AND for the network scanner") + } + // Phase 6 SCALE-M3 closure (2026-05-14): operator-overridable // package-level default for the asyncpoll MaxWait fallback. // Per-connector overrides (CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS, diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index 703e9c2..3099e13 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -256,6 +256,18 @@ func TestMain_ServerConfigFromEnvironment(t *testing.T) { os.Setenv("CERTCTL_SERVER_PORT", "8080") os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath) os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath) + // Acquisition-audit RED-003 closure (Sprint 5 ACQ, 2026-05-16): + // deny-empty default flipped to true; supply a placeholder token + // so Load() succeeds. The defer below restores prior env. + oldBootstrap := os.Getenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN") + os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") + defer func() { + if oldBootstrap != "" { + os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", oldBootstrap) + } else { + os.Unsetenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN") + } + }() cfg, err := config.Load() if err != nil { @@ -317,6 +329,18 @@ func TestMain_AuthTypeConfiguration(t *testing.T) { // Set auth secret for api-key mode os.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + // Acquisition-audit RED-003 closure (Sprint 5 ACQ, 2026-05-16): + // deny-empty default flipped to true; supply a placeholder token + // so Load() succeeds. + oldBootstrap := os.Getenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN") + os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") + defer func() { + if oldBootstrap != "" { + os.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", oldBootstrap) + } else { + os.Unsetenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN") + } + }() testCases := []string{"api-key", "none"} diff --git a/internal/config/config.go b/internal/config/config.go index 3aa4709..a84bc0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -113,6 +113,32 @@ type Config struct { // + window. The scheduler's userRetentionLoop reads Interval; the // UserRetentionService reads RetentionWindow + BatchCap. UserRetention UserRetentionConfig + // Network holds outbound-egress policy tunables. Acquisition-audit + // SEC-009 + RED-005 closure (Sprint 5 ACQ, 2026-05-16). Today the + // only field is BlockRFC1918Outbound; future egress-policy knobs + // (per-host allowlists, max-dial-time overrides) go here. + Network NetworkConfig +} + +// NetworkConfig is the outbound-egress policy surface for certctl. +// Acquisition-audit SEC-009 + RED-005 closure (Sprint 5 ACQ, +// 2026-05-16). +type NetworkConfig struct { + // BlockRFC1918Outbound, when true, extends the SSRF reserved-IP + // gate (internal/validation/ssrf.go::IsReservedIP) to include the + // three RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, + // 192.168.0.0/16). Default false (preserves the certctl threat- + // model default that RFC1918 is legitimate destination space). + // Operators on hosted IaaS where RFC1918 is internal trust + // (Kubernetes service CIDRs that expose the API server inside + // RFC1918, internal-only monitoring stacks, etc.) opt in via + // CERTCTL_BLOCK_RFC1918_OUTBOUND=true. Wired at boot from + // cmd/server/main.go via validation.SetBlockRFC1918Outbound. + // + // IMPORTANT: enabling this also blocks RFC1918 from the certctl + // network scanner. Operators who scan their own RFC1918 space + // for cert-discovery MUST leave this disabled. + BlockRFC1918Outbound bool } // AuditChainConfig configures the audit_events tamper-evidence @@ -464,10 +490,18 @@ func Load() (*Config, error) { // NamedKeys is populated from CERTCTL_API_KEYS_NAMED below so Load() // can surface parse errors alongside other config errors. - // Bundle-5 / Audit H-007: agent-registration bootstrap secret. - // Empty (default) = warn-mode pass-through; v2.2.0 will require it. + // Bundle-5 / Audit H-007 + acquisition-audit RED-003 closure + // (Sprint 5 ACQ, 2026-05-16): agent-registration bootstrap + // secret. The deny-empty default flipped from false → true + // on 2026-05-16. Operators upgrading from v2.1.x can re- + // open the warn-mode escape hatch by explicitly setting + // CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false (one + // upgrade window); see CHANGELOG v2.2.0 for the migration + // note. Demo mode (CERTCTL_DEMO_MODE_ACK=true) keeps the + // pre-flip warn-mode for the screenshot path — see + // Validate() for the override site. AgentBootstrapToken: getEnv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", ""), - AgentBootstrapTokenDenyEmpty: getEnvBool("CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY", false), + AgentBootstrapTokenDenyEmpty: getEnvBool("CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY", true), // Bundle 1 Phase 6: one-shot bootstrap token for the // /v1/auth/bootstrap endpoint that mints the first admin // key. Empty = bootstrap endpoint disabled (default). @@ -754,6 +788,15 @@ func Load() (*Config, error) { RetentionWindow: getEnvDuration("CERTCTL_USER_RETENTION_WINDOW", 30*24*time.Hour), BatchCap: getEnvInt("CERTCTL_USER_RETENTION_BATCH_CAP", 200), }, + // Acquisition-audit SEC-009 + RED-005 closure (Sprint 5 ACQ, + // 2026-05-16). Default false preserves the existing threat-model + // default (RFC1918 is legitimate destination space); operators + // on hosted IaaS opt in via CERTCTL_BLOCK_RFC1918_OUTBOUND=true. + // Wired into validation.SetBlockRFC1918Outbound at boot from + // cmd/server/main.go. + Network: NetworkConfig{ + BlockRFC1918Outbound: getEnvBool("CERTCTL_BLOCK_RFC1918_OUTBOUND", false), + }, } // Parse CERTCTL_API_KEYS_NAMED for named key authentication (M-002). @@ -942,15 +985,21 @@ func (c *Config) Validate() error { return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type) } - // Phase 2 SEC-H1 closure (2026-05-13): the AgentBootstrapTokenDenyEmpty - // staged feature flag. When the operator opts in via - // CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=true AND the bootstrap - // token is empty, Validate() returns a fail-closed error. Default - // flag value is false, preserving the existing v2.1.x warn-mode - // pass-through behavior for backward compatibility. The default-flip - // to true is scheduled for v2.2.0 in WORKSPACE-ROADMAP.md — operators - // get one upgrade window to set a real token. - if c.Auth.AgentBootstrapTokenDenyEmpty && c.Auth.AgentBootstrapToken == "" { + // Phase 2 SEC-H1 closure (2026-05-13) + acquisition-audit RED-003 + // closure (Sprint 5 ACQ, 2026-05-16): the AgentBootstrapTokenDenyEmpty + // fail-closed gate. The flag flipped default from false → true on + // 2026-05-16; operators upgrading from v2.1.x can reopen the + // warn-mode escape hatch with CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false + // for one upgrade window. CHANGELOG v2.2.0 documents the cutover. + // + // Demo-mode override: a screenshot/demo deploy with + // CERTCTL_DEMO_MODE_ACK=true skips this guard so the demo path + // stays one-command-up. The accompanying boot banner WARN in + // cmd/server/main.go keeps the posture visible — demo deploys + // already log a prominent "DEMO MODE ACTIVE" line at every boot. + // Production deploys never set DemoModeAck, so this override + // cannot inadvertently re-enable warn-mode in production. + if c.Auth.AgentBootstrapTokenDenyEmpty && c.Auth.AgentBootstrapToken == "" && !c.Auth.DemoModeAck { return fmt.Errorf("phase-2 SEC-H1 fail-closed guard: %w", ErrAgentBootstrapTokenRequired) } diff --git a/internal/config/config_est_profiles_test.go b/internal/config/config_est_profiles_test.go index 2ce62ee..aad391c 100644 --- a/internal/config/config_est_profiles_test.go +++ b/internal/config/config_est_profiles_test.go @@ -50,6 +50,7 @@ func TestESTConfig_LegacyFlatFields_SynthesizeSingleProfile(t *testing.T) { t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") t.Setenv("CERTCTL_AUTH_TYPE", "api-key") t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") srv := validServerConfig(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) @@ -102,6 +103,7 @@ func TestESTConfig_DisabledNoLegacyShim(t *testing.T) { t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") t.Setenv("CERTCTL_AUTH_TYPE", "api-key") t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") srv := validServerConfig(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) @@ -152,6 +154,7 @@ func TestESTConfig_MultipleProfiles_LoadFromEnv(t *testing.T) { t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") t.Setenv("CERTCTL_AUTH_TYPE", "api-key") t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") srv := validServerConfig(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) @@ -234,6 +237,7 @@ func TestESTConfig_StructuredFormBeatsLegacy(t *testing.T) { t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") t.Setenv("CERTCTL_AUTH_TYPE", "api-key") t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") srv := validServerConfig(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) diff --git a/internal/config/config_scep_profiles_test.go b/internal/config/config_scep_profiles_test.go index a00209e..ae0fb25 100644 --- a/internal/config/config_scep_profiles_test.go +++ b/internal/config/config_scep_profiles_test.go @@ -68,6 +68,7 @@ func TestSCEPConfig_LegacyFlatFields_SynthesizeSingleProfile(t *testing.T) { t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") t.Setenv("CERTCTL_AUTH_TYPE", "api-key") t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") srv := validServerConfig(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) @@ -116,6 +117,7 @@ func TestSCEPConfig_MultipleProfiles_LoadFromEnv(t *testing.T) { t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") t.Setenv("CERTCTL_AUTH_TYPE", "api-key") t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") srv := validServerConfig(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) @@ -162,6 +164,7 @@ func TestSCEPConfig_StructuredFormBeatsLegacy(t *testing.T) { t.Setenv("CERTCTL_DB_URL", "postgres://localhost/certctl?sslmode=disable") t.Setenv("CERTCTL_AUTH_TYPE", "api-key") t.Setenv("CERTCTL_AUTH_SECRET", "test-secret") + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") srv := validServerConfig(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3dc29ad..5d5ee30 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -53,6 +53,14 @@ func setMinimalValidEnv(t *testing.T) { certPath, keyPath := generateTestTLSPair(t) t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath) t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath) + // Acquisition-audit RED-003 closure (Sprint 5 ACQ, 2026-05-16): + // the deny-empty default flipped to true, so Load() now refuses + // to start with an empty bootstrap token. Supply a placeholder + // so Load()-based tests that don't specifically test the + // deny-empty gate continue to pass. Tests that DO exercise the + // empty-token gate explicitly override via + // t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "") after this helper. + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder") } // generateTestTLSPair writes an ECDSA P-256 self-signed certificate + private @@ -413,9 +421,14 @@ func TestLoad_CommaSeparatedList(t *testing.T) { } } -// Phase 2 SEC-H1 (2026-05-13) — AgentBootstrapTokenDenyEmpty staged flag. -// When false (default), an empty token is permitted (v2.1.x warn-mode -// pass-through preserved). When true, an empty token fails closed. +// Phase 2 SEC-H1 (2026-05-13) introduced the AgentBootstrapTokenDenyEmpty +// staged flag with default false. Acquisition-audit RED-003 closure +// (Sprint 5 ACQ, 2026-05-16) flipped the default to true. The test +// below preserves the back-compat path (operator explicitly opts back +// to the v2.1.x warn-mode pass-through); the new default behavior is +// covered by TestLoad_AgentBootstrapTokenDenyEmpty_DefaultIsTrue + +// TestValidate_AgentBootstrapTokenDenyEmpty_True_EmptyTokenFailsClosed +// further down in this file. func TestValidate_AgentBootstrapTokenDenyEmpty_DefaultFalse_AllowsEmpty(t *testing.T) { cfg := &Config{ Server: validServerConfig(t), @@ -2226,3 +2239,112 @@ func TestWarnExternalSslmodeDisable_QuietOnUnparseableOrEmpty(t *testing.T) { }) } } + +// ----------------------------------------------------------------------------- +// Acquisition-audit Sprint 5 ACQ — RED-003 deny-empty default flip +// (2026-05-16). Three new tests pin the new default + the two +// override paths (operator opt-back, demo-mode override). +// ----------------------------------------------------------------------------- + +// TestLoad_AgentBootstrapTokenDenyEmpty_DefaultIsTrue pins the post- +// 2026-05-16 default. Load() with no CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY +// set must produce a Config whose AuthConfig.AgentBootstrapTokenDenyEmpty +// is true. Together with the next test, this proves the default flip +// from false → true at the boot path. +func TestLoad_AgentBootstrapTokenDenyEmpty_DefaultIsTrue(t *testing.T) { + clearCertctlEnv(t) + setMinimalValidEnv(t) + // Set a real bootstrap token so the deny-empty + empty-token guard + // doesn't trip — we're asserting the default flag VALUE here, not + // the guard behavior. + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "a-real-32-byte-token-value-here-x") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() = %v; want nil", err) + } + if !cfg.Auth.AgentBootstrapTokenDenyEmpty { + t.Error("Load() default AgentBootstrapTokenDenyEmpty = false; want true (Sprint 5 ACQ flip on 2026-05-16)") + } +} + +// TestValidate_DenyEmptyDefault_RefusesWithoutToken pins the new +// default's effect: an empty token, with the flag at its +// post-2026-05-16 default of true, fails closed at Validate(). +// Different shape from +// TestValidate_AgentBootstrapTokenDenyEmpty_True_EmptyTokenFailsClosed +// — that test sets the flag explicitly; this one drives the flag +// value from Load() defaults so it tracks any future default flip. +func TestValidate_DenyEmptyDefault_RefusesWithoutToken(t *testing.T) { + clearCertctlEnv(t) + setMinimalValidEnv(t) + // setMinimalValidEnv now sets CERTCTL_AGENT_BOOTSTRAP_TOKEN to + // a placeholder (post-Sprint-5 ACQ default-flip — most Load()- + // based tests need it). Override back to empty here because + // THIS test is specifically the empty-token + default-deny-empty + // fail-closed assertion. + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "") + // CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY deliberately unset + // so the default (true) applies. + + _, err := Load() + if err == nil { + t.Fatal("Load() = nil; want ErrAgentBootstrapTokenRequired (deny-empty default flipped to true; empty token must fail closed)") + } + if !errors.Is(err, ErrAgentBootstrapTokenRequired) { + t.Errorf("Load() err = %v; want errors.Is to match ErrAgentBootstrapTokenRequired", err) + } +} + +// TestValidate_DenyEmptyExplicitFalse_AllowsEmpty pins the v2.1.x +// back-compat path: an operator who explicitly opts out of the new +// default (CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false) keeps the +// warn-mode pass-through. CHANGELOG v2.2.0 documents this as a +// one-upgrade-window escape hatch for operators who haven't generated +// a token yet. +func TestValidate_DenyEmptyExplicitFalse_AllowsEmpty(t *testing.T) { + clearCertctlEnv(t) + setMinimalValidEnv(t) + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY", "false") + // Override setMinimalValidEnv's placeholder so we exercise the + // "operator explicit opt-out + empty token" path. + t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() = %v; want nil (explicit deny-empty=false allows empty token)", err) + } + if cfg.Auth.AgentBootstrapTokenDenyEmpty { + t.Error("AgentBootstrapTokenDenyEmpty = true; want false (operator explicit opt-out)") + } +} + +// TestValidate_DenyEmpty_DemoModeAckOverride_AllowsEmpty pins the +// demo-mode escape hatch. A demo deploy with +// CERTCTL_DEMO_MODE_ACK=true (plus the SEC-H3 24h-fresh TS) keeps +// the warn-mode pass-through even with deny-empty=true. The +// accompanying boot banner WARN in cmd/server/main.go keeps the +// posture visible to log scrapers — demo deploys already emit a +// prominent "DEMO MODE ACTIVE" banner at every boot. +func TestValidate_DenyEmpty_DemoModeAckOverride_AllowsEmpty(t *testing.T) { + cfg := &Config{ + Server: validServerConfig(t), + Database: DatabaseConfig{URL: "postgres://localhost/certctl", MaxConnections: 25}, + Log: LogConfig{Level: "info", Format: "json"}, + Auth: AuthConfig{ + Type: "none", + AgentBootstrapToken: "", + AgentBootstrapTokenDenyEmpty: true, + DemoModeAck: true, + // 24h-fresh TS — SEC-H3 already gates demo-mode boot on + // TS freshness; supply a current epoch so we exercise + // only the deny-empty-override leg, not the SEC-H3 leg. + DemoModeAckTS: strconv.FormatInt(time.Now().Unix(), 10), + }, + Keygen: KeygenConfig{Mode: "agent"}, + Scheduler: validSchedulerConfig(), + } + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() = %v; want nil (demo-mode override should allow empty token)", err) + } +} diff --git a/internal/validation/ssrf.go b/internal/validation/ssrf.go index 1dc86ae..acdaab2 100644 --- a/internal/validation/ssrf.go +++ b/internal/validation/ssrf.go @@ -9,26 +9,94 @@ import ( "net" "net/url" "strings" + "sync/atomic" "time" ) +// blockRFC1918Outbound is the package-level toggle for the +// acquisition-audit SEC-009 + RED-005 closure (Sprint 5 ACQ, +// 2026-05-16). When true, IsReservedIP additionally returns true for +// the RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), +// which by default are NOT reserved (see the IsReservedIP header +// comment for the threat-model rationale). Operators on hosted IaaS +// where RFC1918 IS internal trust (e.g. the kubeadm-default +// 10.96.0.0/12 service CIDR exposes the Kubernetes API server on +// 10.96.0.1) opt in via CERTCTL_BLOCK_RFC1918_OUTBOUND=true. +// +// Stored as atomic.Bool so the hot-path SSRF check in +// SafeHTTPDialContext doesn't need a mutex; SetBlockRFC1918Outbound +// is the single writer (called once at boot from +// cmd/server/main.go via the config.Network.BlockRFC1918Outbound +// value) and IsReservedIP is the reader. Because the toggle is +// boot-time wiring rather than per-request runtime, the relaxed +// memory ordering of atomic.Bool is sufficient and adds no +// measurable per-call overhead. +var blockRFC1918Outbound atomic.Bool + +// SetBlockRFC1918Outbound flips the package-level RFC1918-block +// toggle. Called once at boot from cmd/server/main.go after +// config.Load. Idempotent — operators can re-flip in tests by +// passing the value they want. +// +// Acquisition-audit SEC-009 + RED-005 closure (Sprint 5 ACQ). +func SetBlockRFC1918Outbound(block bool) { + blockRFC1918Outbound.Store(block) +} + +// BlockRFC1918OutboundEnabled reports the current toggle state. +// Exposed so callers (e.g. operator-facing /healthz diagnostics) +// can render the effective SSRF policy without re-reading the env. +func BlockRFC1918OutboundEnabled() bool { + return blockRFC1918Outbound.Load() +} + +// rfc1918Nets is the pre-parsed set of RFC 1918 CIDRs, computed once +// at package init so the IsReservedIP hot path doesn't re-parse the +// strings on every call. A `nil` entry would surface a panic at +// startup rather than silently no-op the toggle. +var rfc1918Nets = func() []*net.IPNet { + out := make([]*net.IPNet, 0, 3) + for _, cidr := range []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"} { + _, n, err := net.ParseCIDR(cidr) + if err != nil || n == nil { + panic("ssrf: failed to pre-parse RFC1918 CIDR " + cidr + ": " + err.Error()) + } + out = append(out, n) + } + return out +}() + // IsReservedIP reports whether the given IP falls inside a range that // outbound HTTP egress (and the network-scanner CIDR expander) MUST treat // as unreachable: loopback, link-local (including cloud-provider metadata // endpoints at 169.254.169.254), multicast, and broadcast. // // RFC 1918 ranges (10/8, 172.16/12, 192.168/16) are intentionally NOT -// treated as reserved. certctl is designed to manage certificates inside -// private networks and filtering private address space would break the -// primary use case. The threat model here is outbound HTTP to -// cloud-metadata or localhost services, not general network reachability. +// treated as reserved by default. certctl is designed to manage +// certificates inside private networks and filtering private address +// space would break the primary use case. The default threat model is +// outbound HTTP to cloud-metadata or localhost services, not general +// network reachability. +// +// Operators on hosted IaaS where RFC1918 IS internal trust (Kubernetes +// service CIDRs that expose the API server inside RFC1918, internal- +// only monitoring stacks, etc.) can opt in via +// CERTCTL_BLOCK_RFC1918_OUTBOUND=true, which the boot path passes to +// SetBlockRFC1918Outbound. When the toggle is on, the three RFC 1918 +// ranges are appended to the reserved set and every code path that +// builds on IsReservedIP (isReservedIPForDial, IsReservedIPForDial, +// SafeHTTPDialContext, ValidateSafeURL, the network scanner, the +// webhook notifier) picks up the policy transitively without per- +// call-site changes. This is acquisition-audit SEC-009 + RED-005 +// closure (Sprint 5 ACQ, 2026-05-16). // // This function is byte-identical in behaviour to the previous unexported -// copy in internal/service/network_scan.go. It is exported here so both -// the network scanner and the webhook notifier share a single -// authoritative implementation. Broader IPv6 coverage and unspecified- -// address handling live in SafeHTTPDialContext, where stricter policy is -// appropriate for outbound HTTP egress. +// copy in internal/service/network_scan.go (for the default-off case). +// It is exported here so both the network scanner and the webhook +// notifier share a single authoritative implementation. Broader IPv6 +// coverage and unspecified- address handling live in +// SafeHTTPDialContext, where stricter policy is appropriate for +// outbound HTTP egress. func IsReservedIP(ip net.IP) bool { // Loopback: 127.0.0.0/8 (and ::1 via IsLoopback). if ip.IsLoopback() { @@ -58,6 +126,19 @@ func IsReservedIP(ip net.IP) bool { return true } + // Acquisition-audit SEC-009 + RED-005 (Sprint 5 ACQ, 2026-05-16). + // Opt-in RFC 1918 block. The toggle is OFF by default — the + // default certctl threat model treats RFC1918 as legitimate + // destination space. Operators on hosted IaaS where RFC1918 is + // internal trust flip this via CERTCTL_BLOCK_RFC1918_OUTBOUND=true. + if blockRFC1918Outbound.Load() { + for _, n := range rfc1918Nets { + if n.Contains(ip) { + return true + } + } + } + return false } diff --git a/internal/validation/ssrf_test.go b/internal/validation/ssrf_test.go index a51d455..8571ff0 100644 --- a/internal/validation/ssrf_test.go +++ b/internal/validation/ssrf_test.go @@ -228,3 +228,70 @@ func TestSafeHTTPDialContext_DefaultTimeoutWhenZero(t *testing.T) { t.Fatal("expected reserved-address rejection") } } + +// TestIsReservedIP_RFC1918_OptIn pins the Sprint 5 ACQ SEC-009 + RED-005 +// closure (2026-05-16). With the default-off toggle, RFC1918 stays +// allowed (the certctl threat-model default). After +// SetBlockRFC1918Outbound(true), the three RFC1918 ranges flip to +// reserved and every IsReservedIP-derived path (isReservedIPForDial, +// SafeHTTPDialContext, ValidateSafeURL, the network scanner) picks +// up the new policy transitively. The defer restores the package-level +// state so subsequent tests don't observe the flipped toggle. +func TestIsReservedIP_RFC1918_OptIn(t *testing.T) { + prior := BlockRFC1918OutboundEnabled() + t.Cleanup(func() { SetBlockRFC1918Outbound(prior) }) + + // Default-off: RFC1918 stays non-reserved. + SetBlockRFC1918Outbound(false) + for _, addr := range []string{"10.0.0.1", "172.16.0.1", "192.168.1.1"} { + ip := net.ParseIP(addr) + if IsReservedIP(ip) { + t.Errorf("default-off: IsReservedIP(%s)=true; want false", addr) + } + } + // Toggle on: same three ranges flip to reserved. + SetBlockRFC1918Outbound(true) + for _, addr := range []string{"10.0.0.1", "10.255.255.254", "172.16.0.1", "172.31.255.254", "192.168.0.1", "192.168.255.254"} { + ip := net.ParseIP(addr) + if !IsReservedIP(ip) { + t.Errorf("toggle-on: IsReservedIP(%s)=false; want true", addr) + } + } + // Edge: a public address right outside RFC1918 (172.32.0.0/12 + // boundary) must STAY non-reserved with the toggle on. + for _, addr := range []string{"172.32.0.1", "11.0.0.1", "192.169.0.1", "9.9.9.9", "8.8.8.8"} { + ip := net.ParseIP(addr) + if IsReservedIP(ip) { + t.Errorf("toggle-on edge: IsReservedIP(%s)=true; want false (just outside RFC1918)", addr) + } + } + // Toggle back off: RFC1918 returns to non-reserved. + SetBlockRFC1918Outbound(false) + for _, addr := range []string{"10.0.0.1", "172.16.0.1", "192.168.1.1"} { + ip := net.ParseIP(addr) + if IsReservedIP(ip) { + t.Errorf("toggle-off after on: IsReservedIP(%s)=true; want false", addr) + } + } +} + +// TestSafeHTTPDialContext_RFC1918_OptIn pins that the toggle reaches +// the SafeHTTPDialContext path transitively (not just IsReservedIP in +// isolation). With toggle off, dialing 10.0.0.1 hits the connection- +// level error (refused/timeout), NOT the "refusing to dial reserved +// address" error. With toggle on, the dial fails closed at the +// reserved-address check BEFORE attempting a TCP SYN. +func TestSafeHTTPDialContext_RFC1918_OptIn(t *testing.T) { + prior := BlockRFC1918OutboundEnabled() + t.Cleanup(func() { SetBlockRFC1918Outbound(prior) }) + + SetBlockRFC1918Outbound(true) + dial := SafeHTTPDialContext(2 * time.Second) + _, err := dial(context.Background(), "tcp", "10.0.0.1:1") + if err == nil { + t.Fatal("toggle-on: expected reserved-address rejection for 10.0.0.1") + } + if !strings.Contains(err.Error(), "refusing to dial reserved address") { + t.Errorf("toggle-on: expected reserved-address message; got: %v", err) + } +}