mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 03:58:52 +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:
@@ -0,0 +1,97 @@
|
||||
// Copyright 2026 certctl LLC. All rights reserved.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
package oidc
|
||||
|
||||
// SEC-001 closure (Sprint 1, 2026-05-16). Pre-fix, two OIDC discovery
|
||||
// call sites passed the bare request context to gooidc.NewProvider:
|
||||
//
|
||||
// - test_discovery.go:65 (dry-run validator from the GUI)
|
||||
// - service.go:1066 (runtime provider load on first cache miss)
|
||||
//
|
||||
// gooidc.NewProvider derives its HTTP client from the context via
|
||||
// oidc.ClientContext; with no override it falls through to
|
||||
// http.DefaultClient. The default client has no SSRF guard, so an admin
|
||||
// with `auth.oidc.create` could induce server-side HTTPS egress to
|
||||
// loopback (127.0.0.1, ::1), RFC 1918 (10/8 / 172.16/12 / 192.168/16),
|
||||
// link-local (169.254.169.254 — cloud-instance metadata), and IPv6
|
||||
// link-local (fe80::/10).
|
||||
//
|
||||
// The companion JWKS reachability probe (jwksReachable + jwksProbeClient
|
||||
// in this package) was already routed through SafeHTTPDialContext via
|
||||
// the Bundle 5 R6 closure; the discovery + claims path bypassed that
|
||||
// guard.
|
||||
//
|
||||
// This file adds the symmetric guard for the discovery leg:
|
||||
//
|
||||
// - oidcDiscoveryClient — an *http.Client wrapping a Transport whose
|
||||
// DialContext is SafeHTTPDialContext, sized to the same outbound
|
||||
// budget as jwksProbeClient (oidcOutboundTimeout = 10s).
|
||||
// - SafeOIDCContext(ctx) — returns a context that gooidc.NewProvider
|
||||
// and the resulting Verifier will use for every outbound call.
|
||||
//
|
||||
// The two call sites above are rewritten to thread their context through
|
||||
// SafeOIDCContext before NewProvider runs. The fail-closed posture is
|
||||
// owned by validation.SafeHTTPDialContext — DNS-rebinding-safe by
|
||||
// re-resolving at dial time and rejecting any reserved address that
|
||||
// surfaces in the resolution.
|
||||
//
|
||||
// Defense-in-depth: domain/types.go.Validate also calls
|
||||
// validation.ValidateSafeURL on the persisted IssuerURL at provider-
|
||||
// creation time so reserved-address issuers fail before they ever reach
|
||||
// the cache + dial path.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
|
||||
"github.com/certctl-io/certctl/internal/validation"
|
||||
)
|
||||
|
||||
// oidcDiscoveryClient is the *http.Client gooidc.NewProvider uses for
|
||||
// the discovery doc fetch + the per-Verifier JWKS read it issues
|
||||
// internally on first sig-verify. Routed through SafeHTTPDialContext
|
||||
// so the dial-time guard re-resolves the issuer host and rejects
|
||||
// loopback / link-local / private / cloud-metadata before any HTTP
|
||||
// byte goes out. Mirrors jwksProbeClient (test_discovery.go) so both
|
||||
// outbound paths share an identical SSRF posture.
|
||||
//
|
||||
// Package-level var so the test suite can swap it for an
|
||||
// SSRF-guard-bypassed client when exercising the discovery code path
|
||||
// against httptest.NewServer (which binds to 127.0.0.1 and would
|
||||
// otherwise be refused). Mirrors the webhook/slack/teams test-seam
|
||||
// pattern. Production code never reassigns this var.
|
||||
var oidcDiscoveryClient = &http.Client{
|
||||
Timeout: oidcOutboundTimeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: validation.SafeHTTPDialContext(oidcOutboundTimeout),
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// SafeOIDCContext returns a derived context that carries the SSRF-safe
|
||||
// discovery http.Client. Pass the result to gooidc.NewProvider so that
|
||||
// the discovery doc fetch + the internal JWKS fetch the resulting
|
||||
// Verifier issues both run through SafeHTTPDialContext.
|
||||
//
|
||||
// Callers SHOULD use this wrapper for every gooidc.NewProvider call
|
||||
// site; the package's own callers (service.go runtime load,
|
||||
// test_discovery.go dry-run validator) do this unconditionally.
|
||||
func SafeOIDCContext(ctx context.Context) context.Context {
|
||||
return gooidc.ClientContext(ctx, oidcDiscoveryClient)
|
||||
}
|
||||
|
||||
// validateIssuerSSRF is the package-level seam tests substitute for the
|
||||
// static issuer-URL SSRF gate. Production callers always run through
|
||||
// validation.ValidateSafeURL; tests using httptest.NewServer (which
|
||||
// binds to 127.0.0.1) swap this to a no-op in setup_test.go so the
|
||||
// loopback URL doesn't trip the early-fail rail. Mirrors the
|
||||
// jwksProbeClient / oidcDiscoveryClient test-seam pattern. Production
|
||||
// code MUST NOT reassign this var.
|
||||
var validateIssuerSSRF = validation.ValidateSafeURL
|
||||
Reference in New Issue
Block a user