mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
5ea45a19b9
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.
533 lines
21 KiB
Go
533 lines
21 KiB
Go
package config
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// EST RFC 7030 hardening master bundle Phase 1: per-issuer EST profiles.
|
|
// These tests pin:
|
|
//
|
|
// 1. Backward-compat shim: legacy CERTCTL_EST_* flat env vars (just
|
|
// CERTCTL_EST_ENABLED + CERTCTL_EST_ISSUER_ID + CERTCTL_EST_PROFILE_ID)
|
|
// synthesise a single-element Profiles[0] with PathID="" so existing
|
|
// /.well-known/est/ operators see no behavior change.
|
|
// 2. Structured form: CERTCTL_EST_PROFILES=corp,iot,wifi expands into
|
|
// per-profile env vars CERTCTL_EST_PROFILE_<NAME>_*.
|
|
// 3. PathID validation: only [a-z0-9-] with no leading/trailing hyphen,
|
|
// empty allowed (legacy root). Validate() refuses anything else.
|
|
// 4. Per-profile gates: Validate() refuses each profile independently
|
|
// (missing IssuerID, mtls-enabled-no-bundle, channel-binding-without-
|
|
// mtls, basic-auth-no-password, mtls-mode-without-mtls, unknown auth
|
|
// mode, negative rate limit, server-keygen without ProfileID,
|
|
// duplicate PathID).
|
|
//
|
|
// Note these tests exercise the loader + Validate() in isolation; the
|
|
// per-profile preflight + router-registration paths are exercised by the
|
|
// router_test (RegisterESTHandlers shape) and the cmd/server/main.go
|
|
// startup path (manual via `make docker-up`).
|
|
|
|
// validBaseConfigForESTProfiles returns a Config that passes Validate
|
|
// EXCEPT for the EST fields the test under exercise sets. Mirrors the
|
|
// existing validBaseConfigForSCEPProfiles helper shape so the test file
|
|
// stays uniform with its siblings.
|
|
func validBaseConfigForESTProfiles(t *testing.T) *Config {
|
|
t.Helper()
|
|
return validBaseConfigForSCEPProfiles(t) // identical infra; EST tests just override the EST block
|
|
}
|
|
|
|
// TestESTConfig_LegacyFlatFields_SynthesizeSingleProfile is the
|
|
// load-time backward-compat test: an operator with the pre-Phase-1
|
|
// flat env vars (no CERTCTL_EST_PROFILES set) must end up with a
|
|
// single-element Profiles slice carrying PathID="" so /.well-known/est/
|
|
// routes the same way it did before.
|
|
func TestESTConfig_LegacyFlatFields_SynthesizeSingleProfile(t *testing.T) {
|
|
clearCertctlEnv(t)
|
|
t.Setenv("CERTCTL_EST_ENABLED", "true")
|
|
t.Setenv("CERTCTL_EST_ISSUER_ID", "iss-legacy-est")
|
|
t.Setenv("CERTCTL_EST_PROFILE_ID", "prof-legacy-est")
|
|
// Required infra envs so Load() doesn't fail on unrelated gates.
|
|
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)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v, want nil (legacy EST flat fields should pass)", err)
|
|
}
|
|
if len(cfg.EST.Profiles) != 1 {
|
|
t.Fatalf("len(Profiles) = %d, want 1 (legacy shim should synthesize single-element slice)", len(cfg.EST.Profiles))
|
|
}
|
|
got := cfg.EST.Profiles[0]
|
|
if got.PathID != "" {
|
|
t.Errorf("Profiles[0].PathID = %q, want \"\" (empty maps to legacy /.well-known/est/ root)", got.PathID)
|
|
}
|
|
if got.IssuerID != "iss-legacy-est" {
|
|
t.Errorf("Profiles[0].IssuerID = %q, want %q", got.IssuerID, "iss-legacy-est")
|
|
}
|
|
if got.ProfileID != "prof-legacy-est" {
|
|
t.Errorf("Profiles[0].ProfileID = %q, want %q", got.ProfileID, "prof-legacy-est")
|
|
}
|
|
// Forward-looking fields should be at their defaults (Phase 2/3/4/5
|
|
// will set non-zero values via the structured form; the legacy shim
|
|
// preserves the pre-Phase-1 unauthenticated/unlimited defaults so
|
|
// existing operators see no behavior change).
|
|
if got.MTLSEnabled {
|
|
t.Errorf("Profiles[0].MTLSEnabled = true, want false (legacy shim preserves pre-Phase-1 defaults)")
|
|
}
|
|
if got.EnrollmentPassword != "" {
|
|
t.Errorf("Profiles[0].EnrollmentPassword = %q, want empty", got.EnrollmentPassword)
|
|
}
|
|
if len(got.AllowedAuthModes) != 0 {
|
|
t.Errorf("Profiles[0].AllowedAuthModes = %v, want empty (back-compat = no auth)", got.AllowedAuthModes)
|
|
}
|
|
if got.RateLimitPerPrincipal24h != 0 {
|
|
t.Errorf("Profiles[0].RateLimitPerPrincipal24h = %d, want 0 (back-compat = unlimited)", got.RateLimitPerPrincipal24h)
|
|
}
|
|
if got.ServerKeygenEnabled {
|
|
t.Errorf("Profiles[0].ServerKeygenEnabled = true, want false (Phase 5 opt-in)")
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_DisabledNoLegacyShim verifies that when EST is disabled
|
|
// the legacy shim is a no-op (Profiles stays empty, no synthesized
|
|
// element). Mirrors the SCEP equivalent.
|
|
func TestESTConfig_DisabledNoLegacyShim(t *testing.T) {
|
|
clearCertctlEnv(t)
|
|
t.Setenv("CERTCTL_EST_ENABLED", "false")
|
|
t.Setenv("CERTCTL_EST_ISSUER_ID", "iss-still-set")
|
|
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)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v, want nil", err)
|
|
}
|
|
if len(cfg.EST.Profiles) != 0 {
|
|
t.Errorf("len(Profiles) = %d, want 0 (disabled EST should not trigger the shim)", len(cfg.EST.Profiles))
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_MultipleProfiles_LoadFromEnv exercises the structured form:
|
|
// CERTCTL_EST_PROFILES=corp,iot,wifi expands into per-profile env vars.
|
|
// All forward-looking fields (auth modes, mTLS, rate limit, server-keygen)
|
|
// load correctly even though the dispatching handlers are Phase 2-5 work.
|
|
func TestESTConfig_MultipleProfiles_LoadFromEnv(t *testing.T) {
|
|
clearCertctlEnv(t)
|
|
t.Setenv("CERTCTL_EST_ENABLED", "true")
|
|
t.Setenv("CERTCTL_EST_PROFILES", "corp,iot,wifi")
|
|
|
|
// CORP: mTLS + Basic, channel-binding required, rate-limited, server-keygen on
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_ISSUER_ID", "iss-corp-laptop")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_PROFILE_ID", "prof-corp-tls")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_ENROLLMENT_PASSWORD", "corp-secret")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_MTLS_ENABLED", "true")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", "/etc/certctl/est/corp-trust.pem")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_CHANNEL_BINDING_REQUIRED", "true")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_ALLOWED_AUTH_MODES", "mtls,basic")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_RATE_LIMIT_PER_PRINCIPAL_24H", "5")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_SERVERKEYGEN_ENABLED", "true")
|
|
|
|
// IOT: Basic only (no mTLS for resource-constrained devices)
|
|
t.Setenv("CERTCTL_EST_PROFILE_IOT_ISSUER_ID", "iss-iot")
|
|
t.Setenv("CERTCTL_EST_PROFILE_IOT_PROFILE_ID", "prof-iot-30d")
|
|
t.Setenv("CERTCTL_EST_PROFILE_IOT_ENROLLMENT_PASSWORD", "iot-bootstrap")
|
|
t.Setenv("CERTCTL_EST_PROFILE_IOT_ALLOWED_AUTH_MODES", "basic")
|
|
t.Setenv("CERTCTL_EST_PROFILE_IOT_RATE_LIMIT_PER_PRINCIPAL_24H", "3")
|
|
|
|
// WIFI: mTLS only (802.1X devices have factory bootstrap certs)
|
|
t.Setenv("CERTCTL_EST_PROFILE_WIFI_ISSUER_ID", "iss-wifi-eaptls")
|
|
t.Setenv("CERTCTL_EST_PROFILE_WIFI_MTLS_ENABLED", "true")
|
|
t.Setenv("CERTCTL_EST_PROFILE_WIFI_MTLS_CLIENT_CA_TRUST_BUNDLE_PATH", "/etc/certctl/est/wifi-trust.pem")
|
|
t.Setenv("CERTCTL_EST_PROFILE_WIFI_ALLOWED_AUTH_MODES", "mtls")
|
|
|
|
// Required infra envs.
|
|
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)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v, want nil", err)
|
|
}
|
|
if len(cfg.EST.Profiles) != 3 {
|
|
t.Fatalf("len(Profiles) = %d, want 3", len(cfg.EST.Profiles))
|
|
}
|
|
|
|
type wantProfile struct {
|
|
PathID, IssuerID, ProfileID, EnrollmentPassword, MTLSBundle string
|
|
MTLSEnabled, ChannelBinding, ServerKeygen bool
|
|
RateLimit int
|
|
AuthModes []string
|
|
}
|
|
wants := map[string]wantProfile{
|
|
"corp": {
|
|
PathID: "corp", IssuerID: "iss-corp-laptop", ProfileID: "prof-corp-tls",
|
|
EnrollmentPassword: "corp-secret", MTLSBundle: "/etc/certctl/est/corp-trust.pem",
|
|
MTLSEnabled: true, ChannelBinding: true, ServerKeygen: true,
|
|
RateLimit: 5, AuthModes: []string{"mtls", "basic"},
|
|
},
|
|
"iot": {
|
|
PathID: "iot", IssuerID: "iss-iot", ProfileID: "prof-iot-30d",
|
|
EnrollmentPassword: "iot-bootstrap",
|
|
RateLimit: 3, AuthModes: []string{"basic"},
|
|
},
|
|
"wifi": {
|
|
PathID: "wifi", IssuerID: "iss-wifi-eaptls",
|
|
MTLSBundle: "/etc/certctl/est/wifi-trust.pem", MTLSEnabled: true,
|
|
AuthModes: []string{"mtls"},
|
|
},
|
|
}
|
|
got := map[string]ESTProfileConfig{}
|
|
for _, p := range cfg.EST.Profiles {
|
|
got[p.PathID] = p
|
|
}
|
|
for name, want := range wants {
|
|
g, ok := got[name]
|
|
if !ok {
|
|
t.Fatalf("missing profile %q in loaded slice", name)
|
|
}
|
|
if g.PathID != want.PathID || g.IssuerID != want.IssuerID || g.ProfileID != want.ProfileID {
|
|
t.Errorf("profile %q identity = (%q,%q,%q), want (%q,%q,%q)",
|
|
name, g.PathID, g.IssuerID, g.ProfileID, want.PathID, want.IssuerID, want.ProfileID)
|
|
}
|
|
if g.EnrollmentPassword != want.EnrollmentPassword {
|
|
t.Errorf("profile %q EnrollmentPassword = %q, want %q", name, g.EnrollmentPassword, want.EnrollmentPassword)
|
|
}
|
|
if g.MTLSEnabled != want.MTLSEnabled || g.MTLSClientCATrustBundlePath != want.MTLSBundle {
|
|
t.Errorf("profile %q mTLS = (%v,%q), want (%v,%q)",
|
|
name, g.MTLSEnabled, g.MTLSClientCATrustBundlePath, want.MTLSEnabled, want.MTLSBundle)
|
|
}
|
|
if g.ChannelBindingRequired != want.ChannelBinding {
|
|
t.Errorf("profile %q ChannelBindingRequired = %v, want %v", name, g.ChannelBindingRequired, want.ChannelBinding)
|
|
}
|
|
if g.ServerKeygenEnabled != want.ServerKeygen {
|
|
t.Errorf("profile %q ServerKeygenEnabled = %v, want %v", name, g.ServerKeygenEnabled, want.ServerKeygen)
|
|
}
|
|
if g.RateLimitPerPrincipal24h != want.RateLimit {
|
|
t.Errorf("profile %q RateLimit = %d, want %d", name, g.RateLimitPerPrincipal24h, want.RateLimit)
|
|
}
|
|
if !equalStringSlices(g.AllowedAuthModes, want.AuthModes) {
|
|
t.Errorf("profile %q AllowedAuthModes = %v, want %v", name, g.AllowedAuthModes, want.AuthModes)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_StructuredFormBeatsLegacy: when CERTCTL_EST_PROFILES is
|
|
// set, the legacy shim is a no-op (the structured form takes precedence).
|
|
func TestESTConfig_StructuredFormBeatsLegacy(t *testing.T) {
|
|
clearCertctlEnv(t)
|
|
t.Setenv("CERTCTL_EST_ENABLED", "true")
|
|
t.Setenv("CERTCTL_EST_ISSUER_ID", "iss-flat-ignored")
|
|
t.Setenv("CERTCTL_EST_PROFILES", "corp")
|
|
t.Setenv("CERTCTL_EST_PROFILE_CORP_ISSUER_ID", "iss-from-structured")
|
|
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)
|
|
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v, want nil", err)
|
|
}
|
|
if len(cfg.EST.Profiles) != 1 {
|
|
t.Fatalf("len(Profiles) = %d, want 1 (structured form), got = %#v", len(cfg.EST.Profiles), cfg.EST.Profiles)
|
|
}
|
|
if got := cfg.EST.Profiles[0].IssuerID; got != "iss-from-structured" {
|
|
t.Errorf("Profiles[0].IssuerID = %q, want structured value (legacy shim should not have fired)", got)
|
|
}
|
|
if got := cfg.EST.Profiles[0].PathID; got != "corp" {
|
|
t.Errorf("Profiles[0].PathID = %q, want \"corp\"", got)
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_PathIDValidation pins validESTPathID + Validate() refusal
|
|
// of malformed PathIDs.
|
|
func TestESTConfig_PathIDValidation(t *testing.T) {
|
|
cases := []struct {
|
|
pathID string
|
|
valid bool
|
|
comment string
|
|
}{
|
|
{"", true, "empty (legacy root)"},
|
|
{"corp", true, "lowercase letters"},
|
|
{"iot-fleet-2", true, "letters + digits + hyphens"},
|
|
{"a", true, "single char"},
|
|
{"-corp", false, "leading hyphen"},
|
|
{"corp-", false, "trailing hyphen"},
|
|
{"Corp", false, "uppercase"},
|
|
{"corp/iot", false, "slash"},
|
|
{"corp.iot", false, "dot"},
|
|
{"corp_iot", false, "underscore"},
|
|
{"corp iot", false, "space"},
|
|
{"corp%20iot", false, "percent encoding"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.comment, func(t *testing.T) {
|
|
if got := validESTPathID(tc.pathID); got != tc.valid {
|
|
t.Errorf("validESTPathID(%q) = %v, want %v (%s)", tc.pathID, got, tc.valid, tc.comment)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_DuplicatePathID_Refuses verifies Validate() refuses two
|
|
// profiles with the same PathID. This is the load-bearing dispatch
|
|
// uniqueness guarantee — without it, the router would silently overwrite
|
|
// the first registration.
|
|
func TestESTConfig_DuplicatePathID_Refuses(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{PathID: "corp", IssuerID: "iss-a"},
|
|
{PathID: "corp", IssuerID: "iss-b"},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for duplicate PathID")
|
|
}
|
|
if !strings.Contains(err.Error(), "duplicates PathID") {
|
|
t.Errorf("Validate() error = %q, want substring \"duplicates PathID\"", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_MissingPerProfileIssuerID verifies Validate() refuses
|
|
// a profile with empty IssuerID.
|
|
func TestESTConfig_MissingPerProfileIssuerID(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{PathID: "corp", IssuerID: ""},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for empty IssuerID")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty IssuerID") {
|
|
t.Errorf("Validate() error = %q, want substring \"empty IssuerID\"", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_MTLSEnabledRequiresBundlePath verifies the per-profile
|
|
// gate: MTLSEnabled=true without MTLS_CLIENT_CA_TRUST_BUNDLE_PATH = refuse.
|
|
func TestESTConfig_MTLSEnabledRequiresBundlePath(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{
|
|
PathID: "corp", IssuerID: "iss-corp",
|
|
MTLSEnabled: true,
|
|
MTLSClientCATrustBundlePath: "", // missing
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for MTLSEnabled without trust bundle")
|
|
}
|
|
if !strings.Contains(err.Error(), "MTLSEnabled=true") {
|
|
t.Errorf("Validate() error = %q, want substring mentioning MTLSEnabled=true", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "/.well-known/est-mtls/corp/") {
|
|
t.Errorf("Validate() error = %q, should reference the sibling route URL operators see", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_ChannelBindingWithoutMTLS_Refuses verifies the cross-check:
|
|
// channel binding only makes sense when mTLS is in use (RFC 9266 binds the
|
|
// TLS-presented client cert to the CSR's CMC attribute).
|
|
func TestESTConfig_ChannelBindingWithoutMTLS_Refuses(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{
|
|
PathID: "corp", IssuerID: "iss-corp",
|
|
MTLSEnabled: false,
|
|
ChannelBindingRequired: true,
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for ChannelBindingRequired without mTLS")
|
|
}
|
|
if !strings.Contains(err.Error(), "ChannelBindingRequired=true but MTLSEnabled=false") {
|
|
t.Errorf("Validate() error = %q, want substring mentioning the cross-check", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_BasicAuthInModesRequiresPassword verifies the cross-check:
|
|
// AllowedAuthModes mentions "basic" → EnrollmentPassword MUST be non-empty.
|
|
func TestESTConfig_BasicAuthInModesRequiresPassword(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{
|
|
PathID: "corp", IssuerID: "iss-corp",
|
|
AllowedAuthModes: []string{"basic"},
|
|
EnrollmentPassword: "", // missing
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for basic auth without password")
|
|
}
|
|
if !strings.Contains(err.Error(), "ENROLLMENT_PASSWORD is empty") {
|
|
t.Errorf("Validate() error = %q, want substring mentioning empty ENROLLMENT_PASSWORD", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_MTLSAuthModeRequiresMTLSEnabled verifies the cross-check:
|
|
// AllowedAuthModes mentions "mtls" → MTLSEnabled MUST be true.
|
|
func TestESTConfig_MTLSAuthModeRequiresMTLSEnabled(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{
|
|
PathID: "corp", IssuerID: "iss-corp",
|
|
AllowedAuthModes: []string{"mtls"},
|
|
MTLSEnabled: false,
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for mtls auth mode without MTLSEnabled")
|
|
}
|
|
if !strings.Contains(err.Error(), "lists \"mtls\" in AllowedAuthModes but MTLSEnabled=false") {
|
|
t.Errorf("Validate() error = %q, want substring mentioning the cross-check", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_UnknownAuthModeRefused verifies Validate() refuses any
|
|
// auth mode that isn't "mtls" or "basic" (typos, future modes the binary
|
|
// doesn't yet implement).
|
|
func TestESTConfig_UnknownAuthModeRefused(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{
|
|
PathID: "corp", IssuerID: "iss-corp",
|
|
AllowedAuthModes: []string{"oauth"}, // not a documented EST auth mode
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for unknown auth mode")
|
|
}
|
|
if !strings.Contains(err.Error(), "unknown AllowedAuthModes entry") {
|
|
t.Errorf("Validate() error = %q, want substring mentioning unknown auth mode", err.Error())
|
|
}
|
|
if !strings.Contains(err.Error(), "oauth") {
|
|
t.Errorf("Validate() error = %q, want to surface the offending mode name", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_NegativeRateLimitRefused verifies Validate() catches the
|
|
// config typo of a negative rate limit.
|
|
func TestESTConfig_NegativeRateLimitRefused(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{
|
|
PathID: "corp", IssuerID: "iss-corp",
|
|
RateLimitPerPrincipal24h: -1,
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for negative rate limit")
|
|
}
|
|
if !strings.Contains(err.Error(), "RATE_LIMIT_PER_PRINCIPAL_24H=-1") {
|
|
t.Errorf("Validate() error = %q, want substring mentioning negative rate limit", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_ServerKeygenRequiresProfileID verifies Validate() refuses
|
|
// ServerKeygenEnabled=true without a CertificateProfile to pin
|
|
// AllowedKeyAlgorithms (the server has to know what to generate).
|
|
func TestESTConfig_ServerKeygenRequiresProfileID(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = true
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{
|
|
PathID: "iot", IssuerID: "iss-iot",
|
|
ServerKeygenEnabled: true,
|
|
ProfileID: "", // missing
|
|
},
|
|
}
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("Validate() = nil, want error for ServerKeygenEnabled without ProfileID")
|
|
}
|
|
if !strings.Contains(err.Error(), "SERVERKEYGEN_ENABLED=true but PROFILE_ID is empty") {
|
|
t.Errorf("Validate() error = %q, want substring mentioning the missing PROFILE_ID", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_DisabledIgnoresProfiles verifies that when EST is disabled,
|
|
// no per-profile validation runs (an operator with a half-configured set of
|
|
// profiles can still flip the kill-switch off without fixing every one).
|
|
func TestESTConfig_DisabledIgnoresProfiles(t *testing.T) {
|
|
cfg := validBaseConfigForESTProfiles(t)
|
|
cfg.EST.Enabled = false
|
|
cfg.EST.Profiles = []ESTProfileConfig{
|
|
{PathID: "BAD-CASE", IssuerID: ""}, // would refuse if EST.Enabled
|
|
{PathID: "corp", IssuerID: ""}, // would refuse if EST.Enabled
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Errorf("Validate() = %v, want nil (disabled EST should skip per-profile gates)", err)
|
|
}
|
|
}
|
|
|
|
// TestESTConfig_ParseAuthModes_Normalization pins the parser's behavior
|
|
// (lowercasing, trimming, empty-element filtering).
|
|
func TestESTConfig_ParseAuthModes_Normalization(t *testing.T) {
|
|
cases := []struct {
|
|
input string
|
|
want []string
|
|
}{
|
|
{"", nil},
|
|
{" ", nil},
|
|
{"mtls", []string{"mtls"}},
|
|
{"MTLS", []string{"mtls"}},
|
|
{"mtls,basic", []string{"mtls", "basic"}},
|
|
{" mtls , basic ", []string{"mtls", "basic"}},
|
|
{"mtls,,basic", []string{"mtls", "basic"}}, // empty element dropped
|
|
{"BASIC", []string{"basic"}},
|
|
}
|
|
for _, tc := range cases {
|
|
got := parseAuthModes(tc.input)
|
|
if !equalStringSlices(got, tc.want) {
|
|
t.Errorf("parseAuthModes(%q) = %v, want %v", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// equalStringSlices reports whether two []string slices contain the same
|
|
// elements in the same order. nil and []string{} are treated as equal.
|
|
func equalStringSlices(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|