Files
certctl/internal/validation/ssrf_test.go
T
shankar0123 5ea45a19b9 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.
2026-05-16 19:13:52 +00:00

298 lines
9.9 KiB
Go

package validation
import (
"context"
"net"
"strings"
"testing"
"time"
)
func TestIsReservedIP_ByteIdenticalWithNetworkScannerBehavior(t *testing.T) {
// These expectations MUST NOT drift from the original unexported
// isReservedIP in internal/service/network_scan.go. Any deviation here
// is a behaviour change in the network scanner and requires a separate,
// deliberate migration.
cases := []struct {
name string
ip string
reserved bool
}{
{"loopback v4", "127.0.0.1", true},
{"loopback v4 range upper", "127.255.255.254", true},
{"loopback v6", "::1", true},
{"AWS metadata", "169.254.169.254", true},
{"link-local range edge", "169.254.0.0", true},
{"multicast 224", "224.0.0.1", true},
{"multicast upper", "239.255.255.255", true},
{"broadcast", "255.255.255.255", true},
// The original network-scanner filter does NOT include unspecified
// or IPv6 link-local, so these must remain non-reserved at this
// layer. Stricter outbound-dial policy lives in SafeHTTPDialContext.
{"unspecified v4", "0.0.0.0", false},
{"IPv6 link-local", "fe80::1", false},
{"IPv6 multicast", "ff00::1", false},
// RFC 1918 is intentionally allowed (self-hosted design).
{"RFC 1918 10/8", "10.0.0.1", false},
{"RFC 1918 172.16/12", "172.16.0.1", false},
{"RFC 1918 192.168/16", "192.168.1.1", false},
// Ordinary public addresses pass.
{"public v4", "8.8.8.8", false},
{"public v6", "2606:4700:4700::1111", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("test setup: failed to parse %q", tc.ip)
}
if got := IsReservedIP(ip); got != tc.reserved {
t.Errorf("IsReservedIP(%s)=%v, want %v", tc.ip, got, tc.reserved)
}
})
}
}
func TestValidateSafeURL_AcceptsSafePublicURLs(t *testing.T) {
cases := []string{
"https://example.com/webhook",
"http://example.com/hook",
"https://example.com:8443/hook",
"https://webhook.site/abc-123",
"http://10.0.0.5/internal", // RFC 1918 allowed
"http://192.168.1.10:8080/webhook", // RFC 1918 allowed
"http://172.16.5.1/intranet", // RFC 1918 allowed
}
for _, raw := range cases {
t.Run(raw, func(t *testing.T) {
if err := ValidateSafeURL(raw); err != nil {
t.Errorf("ValidateSafeURL(%q) unexpectedly failed: %v", raw, err)
}
})
}
}
func TestValidateSafeURL_RejectsReservedLiteralIPs(t *testing.T) {
cases := []struct {
name string
url string
}{
{"loopback v4", "http://127.0.0.1/x"},
{"loopback v4 with port", "http://127.0.0.1:8080/"},
{"loopback v6 bracketed", "http://[::1]/x"},
{"AWS metadata endpoint", "http://169.254.169.254/latest/meta-data/"},
{"link-local IP", "http://169.254.1.2/"},
{"broadcast", "http://255.255.255.255/"},
{"multicast", "https://224.0.0.5/"},
{"unspecified v4", "http://0.0.0.0/"},
{"unspecified v6", "http://[::]/"},
{"IPv6 link-local", "http://[fe80::1]/"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateSafeURL(tc.url)
if err == nil {
t.Fatalf("ValidateSafeURL(%q) returned nil, want error", tc.url)
}
if !strings.Contains(err.Error(), "reserved") {
t.Errorf("error should mention 'reserved' for operator diagnostics, got %q", err.Error())
}
})
}
}
func TestValidateSafeURL_RejectsDangerousSchemes(t *testing.T) {
cases := []struct {
name string
url string
}{
{"file scheme", "file:///etc/passwd"},
{"gopher scheme", "gopher://example.com/"},
{"ftp scheme", "ftp://example.com/"},
{"javascript scheme", "javascript:alert(1)"},
{"data scheme", "data:text/plain;base64,SGVsbG8="},
{"ldap scheme", "ldap://example.com/"},
{"dict scheme", "dict://example.com:2628/d:foo"},
{"jar scheme", "jar:http://example.com/foo.jar!/"},
{"empty scheme", "example.com/hook"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateSafeURL(tc.url)
if err == nil {
t.Fatalf("ValidateSafeURL(%q) returned nil, want error", tc.url)
}
if !strings.Contains(err.Error(), "scheme") && !strings.Contains(err.Error(), "host") {
t.Errorf("error should mention scheme or host, got %q", err.Error())
}
})
}
}
func TestValidateSafeURL_RejectsMissingHost(t *testing.T) {
cases := []string{
"http:///foo",
"https://",
}
for _, raw := range cases {
t.Run(raw, func(t *testing.T) {
err := ValidateSafeURL(raw)
if err == nil {
t.Fatalf("ValidateSafeURL(%q) returned nil, want error", raw)
}
})
}
}
func TestValidateSafeURL_RejectsEmpty(t *testing.T) {
if err := ValidateSafeURL(""); err == nil {
t.Fatal("ValidateSafeURL(\"\") returned nil, want error")
}
}
func TestValidateSafeURL_RejectsMalformed(t *testing.T) {
// url.Parse is famously lax; we lean on the scheme/host checks to catch
// malformed inputs that produce empty schemes or hosts.
cases := []string{
"://missing-scheme",
"http//missing-colon.example.com",
}
for _, raw := range cases {
t.Run(raw, func(t *testing.T) {
err := ValidateSafeURL(raw)
if err == nil {
t.Fatalf("ValidateSafeURL(%q) returned nil, want error", raw)
}
})
}
}
func TestSafeHTTPDialContext_RejectsLiteralReservedAddress(t *testing.T) {
dial := SafeHTTPDialContext(2 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cases := []string{
"127.0.0.1:9",
"169.254.169.254:80",
"[::1]:22",
"0.0.0.0:80",
}
for _, addr := range cases {
t.Run(addr, func(t *testing.T) {
conn, err := dial(ctx, "tcp", addr)
if err == nil {
_ = conn.Close()
t.Fatalf("dial(%q) returned nil err, want reserved-address rejection", addr)
}
if !strings.Contains(err.Error(), "reserved") {
t.Errorf("expected reserved-address rejection, got %q", err.Error())
}
})
}
}
func TestSafeHTTPDialContext_RejectsHostResolvingToReservedAddress(t *testing.T) {
// The stdlib resolver treats "localhost" as 127.0.0.1 / ::1 on every
// platform we care about; this exercises the post-resolution check and
// demonstrates that DNS-rebinding attacks (where a name points at a
// reserved IP) are rejected at dial time rather than at validation time.
dial := SafeHTTPDialContext(2 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := dial(ctx, "tcp", "localhost:9")
if err == nil {
_ = conn.Close()
t.Fatal("dial(localhost:9) returned nil err, want reserved-address rejection")
}
if !strings.Contains(err.Error(), "reserved") {
t.Errorf("expected reserved-address rejection for localhost, got %q", err.Error())
}
}
func TestSafeHTTPDialContext_InvalidAddress(t *testing.T) {
dial := SafeHTTPDialContext(1 * time.Second)
_, err := dial(context.Background(), "tcp", "no-port")
if err == nil {
t.Fatal("expected error for invalid dial address, got nil")
}
}
func TestSafeHTTPDialContext_DefaultTimeoutWhenZero(t *testing.T) {
// Not directly observable, but we at least exercise the branch to
// prevent a nil-ptr regression if the timeout default is dropped.
dial := SafeHTTPDialContext(0)
_, err := dial(context.Background(), "tcp", "127.0.0.1:1")
if err == nil {
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)
}
}