mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 18:28:53 +00:00
fix(auth): SEC-001 — gate OIDC discovery through SafeHTTPDialContext + ValidateSafeURL
Sprint 1 unified-master-audit closure. Two OIDC discovery call sites
passed the bare request context to gooidc.NewProvider:
- internal/auth/oidc/test_discovery.go:65 (dry-run validator)
- internal/auth/oidc/service.go:1066 (runtime cache load)
gooidc.NewProvider derives its HTTP client from the context via
oidc.ClientContext; with no override it falls through to
http.DefaultClient — no SSRF guard. An admin with auth.oidc.create
could induce server-side HTTPS egress to loopback (127.0.0.1, ::1),
RFC 1918, link-local (169.254.169.254 — cloud-instance metadata),
and IPv6 link-local (fe80::/10). The companion JWKS reachability
probe was already routed through SafeHTTPDialContext via the
Bundle 5 R6 closure; the discovery + claims path bypassed that.
Fix:
- New internal/auth/oidc/safehttp.go: oidcDiscoveryClient (Transport
DialContext = validation.SafeHTTPDialContext) + SafeOIDCContext
helper. Both call sites now wrap ctx through SafeOIDCContext
before NewProvider runs.
- Defense-in-depth: OIDCProvider.Validate calls
validation.ValidateSafeURL on the IssuerURL after the existing
https/parse checks, refusing reserved-address issuers at
provider-creation time.
- TestDiscovery surfaces the SSRF policy error via the result's
Errors slice up-front (early-fail UX rail) before invoking
NewProvider.
Test seams:
- setup_test.go swaps oidcDiscoveryClient + validateIssuerSSRF
for httptest loopback compatibility, mirroring the existing
jwksProbeClient pattern.
Regression coverage:
- internal/auth/oidc/domain/types_test.go: 5-case table pinning
loopback v4/v6, cloud metadata, link-local v4/v6 rejection.
- internal/auth/oidc/coverage_fill_test.go: same 5 cases against
Service.TestDiscovery via temporarily restoring the production
gate.
Closes SEC-001.
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
"time"
|
||||
|
||||
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
||||
"github.com/certctl-io/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// OIDCProvider describes a configured OpenID Connect identity provider
|
||||
@@ -160,6 +161,16 @@ func (p *OIDCProvider) Validate() error {
|
||||
if _, err := url.Parse(p.IssuerURL); err != nil {
|
||||
return fmt.Errorf("oidc: issuer_url is not a valid URL: %w", err)
|
||||
}
|
||||
// SEC-001 closure (Sprint 1, 2026-05-16): reject reserved-address
|
||||
// issuers (loopback / RFC 1918 / link-local / cloud metadata) at
|
||||
// provider-creation time. Defense-in-depth alongside
|
||||
// oidc.SafeOIDCContext, which is the authoritative dial-time
|
||||
// re-resolution + reject. The static URL check stops the obvious
|
||||
// case ("https://169.254.169.254/...") before the row is persisted
|
||||
// or the dry-run validator runs.
|
||||
if err := validation.ValidateSafeURL(p.IssuerURL); err != nil {
|
||||
return fmt.Errorf("oidc: issuer_url failed SSRF policy: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(p.ClientID) == "" {
|
||||
return ErrOIDCEmptyClientID
|
||||
}
|
||||
|
||||
@@ -82,6 +82,41 @@ func TestOIDCProvider_Validate_RejectsNonHTTPSIssuer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// SEC-001 closure (Sprint 1, 2026-05-16). The IssuerURL Validate gate
|
||||
// now refuses reserved-address issuers (loopback, RFC 1918,
|
||||
// link-local, IPv6 loopback, IPv6 link-local, cloud metadata) so a
|
||||
// row claiming https://127.0.0.1/... or https://169.254.169.254/...
|
||||
// never makes it to the persistence layer or the runtime discovery
|
||||
// dial. Authoritative dial-time rejection lives in
|
||||
// internal/validation.SafeHTTPDialContext (DNS-rebinding-safe); this
|
||||
// test pins the static URL gate that surfaces the policy violation
|
||||
// with a clean error before any network I/O.
|
||||
func TestOIDCProvider_Validate_RejectsSSRFIssuer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
issuer string
|
||||
}{
|
||||
{"loopback_v4", "https://127.0.0.1/realms/certctl"},
|
||||
{"loopback_v6", "https://[::1]/realms/certctl"},
|
||||
{"cloud_metadata", "https://169.254.169.254/latest/meta-data/"},
|
||||
{"link_local_v4", "https://169.254.10.5/realms/certctl"},
|
||||
{"link_local_v6", "https://[fe80::1]/realms/certctl"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := validProvider()
|
||||
p.IssuerURL = tc.issuer
|
||||
err := p.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("issuer=%q: Validate returned nil; want SSRF policy rejection", tc.issuer)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "SSRF policy") {
|
||||
t.Errorf("issuer=%q: err=%v; want error mentioning SSRF policy", tc.issuer, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCProvider_Validate_RejectsEmptyClientID(t *testing.T) {
|
||||
p := validProvider()
|
||||
p.ClientID = ""
|
||||
|
||||
Reference in New Issue
Block a user