Files
certctl/internal/auth/oidc/safehttp.go
T
shankar0123 5d7bc86451 fix(oidc): SEC-020 — wrap fetchUserinfoGroups via SafeOIDCContext
Acquisition-audit Sprint 1 follow-up to SEC-001 (2026-05-16). The
original SEC-001 sweep routed two OIDC discovery legs (test_discovery.go
dry-run + service.go runtime provider load) through
validation.SafeHTTPDialContext via the SafeOIDCContext(ctx) helper.
This commit closes one of the two adjacent call sites the sweep missed:
the userinfo-fallback path at service.go::fetchUserinfoGroups.

Pre-fix:

    func (s *Service) fetchUserinfoGroups(ctx, entry, token, path) {
        ...
        ts := entry.oauthConfig.TokenSource(ctx, token)
        uinfo, err := entry.provider.UserInfo(ctx, ts)
        ...
    }

go-oidc/v3 Provider.UserInfo (oidc.go:351-374) derives its
http.Client from ctx via getClient(ctx) (oidc.go:61-65). Without an
override, the internal doRequest (oidc.go:87-92) falls through to
http.DefaultClient — no SSRF guard, no DNS-rebinding re-resolve at
dial time. An IdP whose discovery doc advertises a userinfo_endpoint
pointing at a reserved address (loopback / link-local /
169.254.169.254 cloud-metadata) would trigger an unguarded HTTPS
egress at userinfo-fetch time. Operator opt-in to fetch_userinfo=true
turns the gap on; the leg fires whenever the ID token doesn't surface
the configured groups claim.

Post-fix:

    safeCtx := SafeOIDCContext(ctx)
    ts := entry.oauthConfig.TokenSource(safeCtx, token)
    uinfo, err := entry.provider.UserInfo(safeCtx, ts)

Context-key shape: gooidc.ClientContext is implemented as
context.WithValue(ctx, oauth2.HTTPClient, client) (go-oidc v3.18.0
oidc.go:57-59). Both go-oidc's getClient AND golang.org/x/oauth2's
internal.ContextClient read the same oauth2.HTTPClient key, so the
SINGLE SafeOIDCContext wrap covers go-oidc-driven HTTP calls
(Provider.UserInfo / Verifier JWKS) AND oauth2-driven HTTP calls
(Config.TokenSource refresh / Exchange). No additional
context.WithValue(ctx, oauth2.HTTPClient, ...) is required.

Files touched:
  internal/auth/oidc/service.go — wrap ctx in fetchUserinfoGroups
  internal/auth/oidc/safehttp.go — extend SEC-001 header comment block
    to enumerate the two newly-patched sites (SEC-020 here +
    SEC-021 in the next commit) and the oauth2.HTTPClient key-sharing
    rationale, so future audits don't re-flag the design as confused
  internal/auth/oidc/service_test.go — new test
    TestFetchUserinfoGroups_SSRF_BlocksReservedAddress that
    stands up a loopback discovery server whose discovery doc
    advertises userinfo_endpoint = http://169.254.169.254/userinfo,
    constructs *gooidc.Provider via the test-bypassed
    oidcDiscoveryClient (setup_test.go's init() pattern), then
    RESTORES the production SafeHTTPDialContext-backed client just
    before the fetchUserinfoGroups call. Asserts the error wraps
    SafeHTTPDialContext's 'refusing to dial reserved address'
    rejection rather than a generic connect-refused. Companion to
    the TestDefaultBCLVerifier_SSRF_BlocksReservedAddress that
    SEC-021 (next commit) adds.

Verified:
  gofmt -l internal/ docs/                                (clean)
  go vet ./...                                            (clean)
  go test -race -short ./internal/auth/oidc/...           (all green)
  TestFetchUserinfoGroups_SSRF_BlocksReservedAddress      (new; green)
  All 4 cited CI guards pass (openapi-handler-parity,
    openapi-codegen-drift, no-sh-c-in-connectors, skip-inventory-drift)

Acceptance grep:
  internal/auth/oidc/service.go:963: uinfo, err := entry.provider.UserInfo(safeCtx, ts)
  internal/auth/oidc/service.go:1084: provider, err := gooidc.NewProvider(SafeOIDCContext(ctx), cfgRow.IssuerURL)

No bare-ctx UserInfo / NewProvider remains in service.go.

Closes acquisition-audit SEC-020. SEC-021 (BCL discovery re-fetch)
lands in the next commit.
2026-05-16 16:41:05 +00:00

123 lines
5.6 KiB
Go

// 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)
//
// Acquisition-audit follow-up SEC-020 + SEC-021 (Sprint 1 follow-up,
// 2026-05-16) extended the same wrap to two adjacent call sites that
// the original SEC-001 sweep missed:
//
// - service.go::fetchUserinfoGroups (~L948-961, SEC-020 closure) —
// the userinfo-fallback path called entry.provider.UserInfo(ctx, ts)
// with bare ctx. go-oidc/v3 Provider.UserInfo derives its HTTP
// client from the context via getClient(ctx) (oidc.go:61-65);
// without an override, the internal doRequest falls through to
// http.DefaultClient.
// - internal/api/handler/auth_session_oidc_bcl.go::Verify (~L125,
// SEC-021 closure) — the back-channel-logout verifier performs a
// per-request discovery re-fetch via gooidc.NewProvider(ctx, ...)
// with bare ctx; SafeOIDCContext now wraps before the call.
//
// Context-key shape: gooidc.ClientContext is implemented as
// context.WithValue(ctx, oauth2.HTTPClient, client)
// (go-oidc v3.18.0 oidc.go:57-59). Both go-oidc's getClient AND
// golang.org/x/oauth2's internal.ContextClient read oauth2.HTTPClient,
// so the SINGLE SafeOIDCContext wrap covers go-oidc-driven HTTP calls
// (Provider.UserInfo / NewProvider discovery / Verifier JWKS) AND
// oauth2-driven HTTP calls (Config.TokenSource refresh / Exchange).
// No additional context.WithValue(ctx, oauth2.HTTPClient, ...) is
// required alongside the wrap.
//
// 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