mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
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:
+29
-8
@@ -48,6 +48,7 @@ import (
|
|||||||
"github.com/certctl-io/certctl/internal/service"
|
"github.com/certctl-io/certctl/internal/service"
|
||||||
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
authsvc "github.com/certctl-io/certctl/internal/service/auth"
|
||||||
"github.com/certctl-io/certctl/internal/trustanchor"
|
"github.com/certctl-io/certctl/internal/trustanchor"
|
||||||
|
"github.com/certctl-io/certctl/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
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.")
|
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
|
// Bundle-5 / Audit H-007 + acquisition-audit RED-003 closure
|
||||||
// token is unset. Pre-Bundle-5 there was no token at all; the v2.0.x
|
// (Sprint 5 ACQ, 2026-05-16): deny-empty default for the agent
|
||||||
// default keeps the warn-mode pass-through so existing demo deploys
|
// bootstrap token. v2.2.0 flipped CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY
|
||||||
// keep working, but operators must set CERTCTL_AGENT_BOOTSTRAP_TOKEN
|
// from false → true; Validate() now refuses to start with an
|
||||||
// before v2.2.0 lands. This is a one-shot startup line — the
|
// empty token UNLESS the operator either (a) explicitly opts back
|
||||||
// per-request path stays silent so a busy registration endpoint
|
// into v2.1.x warn-mode with CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false
|
||||||
// doesn't flood the log.
|
// 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 == "" {
|
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 {
|
} else {
|
||||||
logger.Info("agent bootstrap token configured (length redacted; constant-time compare on POST /api/v1/agents)")
|
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
|
// Phase 6 SCALE-M3 closure (2026-05-14): operator-overridable
|
||||||
// package-level default for the asyncpoll MaxWait fallback.
|
// package-level default for the asyncpoll MaxWait fallback.
|
||||||
// Per-connector overrides (CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS,
|
// Per-connector overrides (CERTCTL_DIGICERT_POLL_MAX_WAIT_SECONDS,
|
||||||
|
|||||||
@@ -256,6 +256,18 @@ func TestMain_ServerConfigFromEnvironment(t *testing.T) {
|
|||||||
os.Setenv("CERTCTL_SERVER_PORT", "8080")
|
os.Setenv("CERTCTL_SERVER_PORT", "8080")
|
||||||
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
os.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
||||||
os.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -317,6 +329,18 @@ func TestMain_AuthTypeConfiguration(t *testing.T) {
|
|||||||
|
|
||||||
// Set auth secret for api-key mode
|
// Set auth secret for api-key mode
|
||||||
os.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
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"}
|
testCases := []string{"api-key", "none"}
|
||||||
|
|
||||||
|
|||||||
+61
-12
@@ -113,6 +113,32 @@ type Config struct {
|
|||||||
// + window. The scheduler's userRetentionLoop reads Interval; the
|
// + window. The scheduler's userRetentionLoop reads Interval; the
|
||||||
// UserRetentionService reads RetentionWindow + BatchCap.
|
// UserRetentionService reads RetentionWindow + BatchCap.
|
||||||
UserRetention UserRetentionConfig
|
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
|
// 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()
|
// 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.
|
||||||
|
|
||||||
// Bundle-5 / Audit H-007: agent-registration bootstrap secret.
|
// Bundle-5 / Audit H-007 + acquisition-audit RED-003 closure
|
||||||
// Empty (default) = warn-mode pass-through; v2.2.0 will require it.
|
// (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", ""),
|
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
|
// Bundle 1 Phase 6: one-shot bootstrap token for the
|
||||||
// /v1/auth/bootstrap endpoint that mints the first admin
|
// /v1/auth/bootstrap endpoint that mints the first admin
|
||||||
// key. Empty = bootstrap endpoint disabled (default).
|
// key. Empty = bootstrap endpoint disabled (default).
|
||||||
@@ -754,6 +788,15 @@ func Load() (*Config, error) {
|
|||||||
RetentionWindow: getEnvDuration("CERTCTL_USER_RETENTION_WINDOW", 30*24*time.Hour),
|
RetentionWindow: getEnvDuration("CERTCTL_USER_RETENTION_WINDOW", 30*24*time.Hour),
|
||||||
BatchCap: getEnvInt("CERTCTL_USER_RETENTION_BATCH_CAP", 200),
|
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).
|
// 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)
|
return fmt.Errorf("auth secret is required for auth type %s", c.Auth.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2 SEC-H1 closure (2026-05-13): the AgentBootstrapTokenDenyEmpty
|
// Phase 2 SEC-H1 closure (2026-05-13) + acquisition-audit RED-003
|
||||||
// staged feature flag. When the operator opts in via
|
// closure (Sprint 5 ACQ, 2026-05-16): the AgentBootstrapTokenDenyEmpty
|
||||||
// CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=true AND the bootstrap
|
// fail-closed gate. The flag flipped default from false → true on
|
||||||
// token is empty, Validate() returns a fail-closed error. Default
|
// 2026-05-16; operators upgrading from v2.1.x can reopen the
|
||||||
// flag value is false, preserving the existing v2.1.x warn-mode
|
// warn-mode escape hatch with CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false
|
||||||
// pass-through behavior for backward compatibility. The default-flip
|
// for one upgrade window. CHANGELOG v2.2.0 documents the cutover.
|
||||||
// to true is scheduled for v2.2.0 in WORKSPACE-ROADMAP.md — operators
|
//
|
||||||
// get one upgrade window to set a real token.
|
// Demo-mode override: a screenshot/demo deploy with
|
||||||
if c.Auth.AgentBootstrapTokenDenyEmpty && c.Auth.AgentBootstrapToken == "" {
|
// 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)
|
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_DB_URL", "postgres://localhost/certctl?sslmode=disable")
|
||||||
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
||||||
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||||
srv := validServerConfig(t)
|
srv := validServerConfig(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
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_DB_URL", "postgres://localhost/certctl?sslmode=disable")
|
||||||
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
||||||
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||||
srv := validServerConfig(t)
|
srv := validServerConfig(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
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_DB_URL", "postgres://localhost/certctl?sslmode=disable")
|
||||||
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
||||||
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||||
srv := validServerConfig(t)
|
srv := validServerConfig(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
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_DB_URL", "postgres://localhost/certctl?sslmode=disable")
|
||||||
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
||||||
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||||
srv := validServerConfig(t)
|
srv := validServerConfig(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
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_DB_URL", "postgres://localhost/certctl?sslmode=disable")
|
||||||
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
||||||
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||||
srv := validServerConfig(t)
|
srv := validServerConfig(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
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_DB_URL", "postgres://localhost/certctl?sslmode=disable")
|
||||||
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
||||||
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||||
srv := validServerConfig(t)
|
srv := validServerConfig(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
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_DB_URL", "postgres://localhost/certctl?sslmode=disable")
|
||||||
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
t.Setenv("CERTCTL_AUTH_TYPE", "api-key")
|
||||||
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
t.Setenv("CERTCTL_AUTH_SECRET", "test-secret")
|
||||||
|
t.Setenv("CERTCTL_AGENT_BOOTSTRAP_TOKEN", "test-bootstrap-token-placeholder")
|
||||||
srv := validServerConfig(t)
|
srv := validServerConfig(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", srv.TLS.CertPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", srv.TLS.KeyPath)
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ func setMinimalValidEnv(t *testing.T) {
|
|||||||
certPath, keyPath := generateTestTLSPair(t)
|
certPath, keyPath := generateTestTLSPair(t)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
t.Setenv("CERTCTL_SERVER_TLS_CERT_PATH", certPath)
|
||||||
t.Setenv("CERTCTL_SERVER_TLS_KEY_PATH", keyPath)
|
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
|
// 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.
|
// Phase 2 SEC-H1 (2026-05-13) introduced the AgentBootstrapTokenDenyEmpty
|
||||||
// When false (default), an empty token is permitted (v2.1.x warn-mode
|
// staged flag with default false. Acquisition-audit RED-003 closure
|
||||||
// pass-through preserved). When true, an empty token fails closed.
|
// (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) {
|
func TestValidate_AgentBootstrapTokenDenyEmpty_DefaultFalse_AllowsEmpty(t *testing.T) {
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Server: validServerConfig(t),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,26 +9,94 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"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
|
// IsReservedIP reports whether the given IP falls inside a range that
|
||||||
// outbound HTTP egress (and the network-scanner CIDR expander) MUST treat
|
// outbound HTTP egress (and the network-scanner CIDR expander) MUST treat
|
||||||
// as unreachable: loopback, link-local (including cloud-provider metadata
|
// as unreachable: loopback, link-local (including cloud-provider metadata
|
||||||
// endpoints at 169.254.169.254), multicast, and broadcast.
|
// endpoints at 169.254.169.254), multicast, and broadcast.
|
||||||
//
|
//
|
||||||
// RFC 1918 ranges (10/8, 172.16/12, 192.168/16) are intentionally NOT
|
// RFC 1918 ranges (10/8, 172.16/12, 192.168/16) are intentionally NOT
|
||||||
// treated as reserved. certctl is designed to manage certificates inside
|
// treated as reserved by default. certctl is designed to manage
|
||||||
// private networks and filtering private address space would break the
|
// certificates inside private networks and filtering private address
|
||||||
// primary use case. The threat model here is outbound HTTP to
|
// space would break the primary use case. The default threat model is
|
||||||
// cloud-metadata or localhost services, not general network reachability.
|
// 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
|
// This function is byte-identical in behaviour to the previous unexported
|
||||||
// copy in internal/service/network_scan.go. It is exported here so both
|
// copy in internal/service/network_scan.go (for the default-off case).
|
||||||
// the network scanner and the webhook notifier share a single
|
// It is exported here so both the network scanner and the webhook
|
||||||
// authoritative implementation. Broader IPv6 coverage and unspecified-
|
// notifier share a single authoritative implementation. Broader IPv6
|
||||||
// address handling live in SafeHTTPDialContext, where stricter policy is
|
// coverage and unspecified- address handling live in
|
||||||
// appropriate for outbound HTTP egress.
|
// SafeHTTPDialContext, where stricter policy is appropriate for
|
||||||
|
// outbound HTTP egress.
|
||||||
func IsReservedIP(ip net.IP) bool {
|
func IsReservedIP(ip net.IP) bool {
|
||||||
// Loopback: 127.0.0.0/8 (and ::1 via IsLoopback).
|
// Loopback: 127.0.0.0/8 (and ::1 via IsLoopback).
|
||||||
if ip.IsLoopback() {
|
if ip.IsLoopback() {
|
||||||
@@ -58,6 +126,19 @@ func IsReservedIP(ip net.IP) bool {
|
|||||||
return true
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,3 +228,70 @@ func TestSafeHTTPDialContext_DefaultTimeoutWhenZero(t *testing.T) {
|
|||||||
t.Fatal("expected reserved-address rejection")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user