feat(security): Sprint 5 ACQ — RED-003 deny-empty flip + SEC-009/RED-005 RFC1918 opt-in

Acquisition-audit Sprint 5 ACQ closure (2026-05-16). Two
independent findings ship together because they share Load() /
main.go wiring; the closure comments tie each line to its finding.

PART A — RED-003 (agent-bootstrap deny-empty cutover)
=====================================================

Phase 2 SEC-H1 closure (2026-05-13) introduced the
CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY staged feature flag with
default `false` so v2.1.x operators wouldn't get a surprise
fail-closed on upgrade. This commit flips the default to `true`
(per the staged plan in the existing CHANGELOG "Breaking changes
(scheduled for v2.2.0)" block). Operators who haven't generated a
real bootstrap token yet keep the v2.1.x warn-mode pass-through
for one upgrade window by setting
CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false explicitly.

Demo-mode escape hatch: CERTCTL_DEMO_MODE_ACK=true skips the
fail-closed gate so the screenshot/demo path stays one-command-up.
The accompanying boot-banner WARN at cmd/server/main.go:124-126
keeps demo mode visible in every log scraper, so this override
cannot silently re-enable warn-mode in production.

internal/config/config.go
  - Load() default for AgentBootstrapTokenDenyEmpty flipped to true
  - Validate() gate now also checks !c.Auth.DemoModeAck so the demo
    override line up with the boot-banner WARN
  - Closure comment block updated to cross-reference Sprint 5 ACQ
    and the CHANGELOG v2.2.0 entry

cmd/server/main.go
  - Updated boot-time WARN message to reflect the new default
    (deny-empty=true) — the warn now fires only in the two
    explicit override scenarios (warn-mode opt-back or demo mode),
    and explains the operator action either way
  - Info-line on configured-token path unchanged

PART B — SEC-009 + RED-005 (opt-in RFC1918 outbound block)
==========================================================

internal/validation/ssrf.go::IsReservedIP has always intentionally
left RFC 1918 ranges (10/8, 172.16/12, 192.168/16) NOT-reserved
because certctl is designed to manage certificates inside private
networks. For operators on hosted IaaS where RFC1918 IS internal
trust (kubeadm-default 10.96.0.0/12 service CIDR exposes the
Kubernetes API on 10.96.0.1; cloud-provider internal monitoring;
hosted-bastion subnets), this default is a real exposure path.

Add a package-level atomic.Bool toggle in internal/validation/ssrf.go
that, when on, extends IsReservedIP to ALSO return true for the
three RFC1918 ranges. Every IsReservedIP-derived path
(SafeHTTPDialContext, ValidateSafeURL, the network scanner, the
webhook + OIDC + ACME callers) picks up the new policy
transitively without per-call-site changes.

internal/validation/ssrf.go
  - blockRFC1918Outbound atomic.Bool + SetBlockRFC1918Outbound /
    BlockRFC1918OutboundEnabled accessor pair
  - rfc1918Nets pre-parsed at package init (panic on parse failure
    surfaces a misconfigured ssrf package immediately, not via a
    silently disabled toggle)
  - IsReservedIP checks the toggle after the existing reserved-IP
    checks
  - Header comment rewritten to document the toggle + the
    transitive coverage

internal/config/config.go
  - New NetworkConfig sub-config; Config gains a Network field
  - Load() reads CERTCTL_BLOCK_RFC1918_OUTBOUND env var (default
    false; preserves the existing self-hosted threat model)
  - NetworkConfig docstring lists the operator-trap (enabling this
    also blocks RFC1918 from the network scanner) so an operator
    cert-discovering their own RFC1918 space doesn't get a
    silently-empty scan result

cmd/server/main.go
  - Wires validation.SetBlockRFC1918Outbound after config.Load and
    near the demo-mode banner / agent-bootstrap-token block; emits
    a one-shot INFO line when the toggle is enabled so the policy
    is visible in journals

Tests
=====

internal/config/config_test.go
  - TestLoad_AgentBootstrapTokenDenyEmpty_DefaultIsTrue — pins the
    default flip at the boot path (Load returns the flipped value)
  - TestValidate_DenyEmptyDefault_RefusesWithoutToken — pins the
    fail-closed behavior under the new default
  - TestValidate_DenyEmptyExplicitFalse_AllowsEmpty — pins the
    v2.1.x back-compat escape hatch
  - TestValidate_DenyEmpty_DemoModeAckOverride_AllowsEmpty — pins
    the demo-mode override

internal/validation/ssrf_test.go
  - TestIsReservedIP_RFC1918_OptIn — pins toggle-off / toggle-on
    behavior across all three RFC1918 ranges, edge cases
    immediately outside the ranges, and the toggle-back-off path
  - TestSafeHTTPDialContext_RFC1918_OptIn — pins that the toggle
    reaches the dial-time SSRF check transitively (not just
    IsReservedIP in isolation)

Test-helper updates (Sprint-5-induced churn):
  - internal/config/config_test.go::setMinimalValidEnv now sets
    CERTCTL_AGENT_BOOTSTRAP_TOKEN to a placeholder so Load()-based
    tests that don't specifically exercise the empty-token gate
    keep passing under the new fail-closed default. Tests that DO
    exercise the empty-token path explicitly override back to "".
  - internal/config/config_est_profiles_test.go +
    internal/config/config_scep_profiles_test.go: same placeholder
    fix for the four Load()-based EST/SCEP profile tests.
  - cmd/server/main_test.go::TestMain_ServerConfigFromEnvironment +
    TestMain_AuthTypeConfiguration: same fix at the main.go test
    layer with prior-value restore.

Verified locally: gofmt -l clean; go vet clean; staticcheck clean
across internal/config, internal/validation, cmd/server; short
tests green on all three packages; targeted -v run of all six new
test names confirms PASS.
This commit is contained in:
shankar0123
2026-05-16 19:13:52 +00:00
parent 374ec574c5
commit 5ea45a19b9
8 changed files with 403 additions and 32 deletions
+29 -8
View File
@@ -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,
+24
View File
@@ -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"}
+61 -12
View File
@@ -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)
}
@@ -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)
@@ -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)
+125 -3
View File
@@ -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)
}
}
+90 -9
View File
@@ -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
}
+67
View File
@@ -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)
}
}