Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions internal/server/scheduledauth/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"time"

"github.com/coreos/go-oidc/v3/oidc"

"github.com/LeanerCloud/CUDly/pkg/httpclient"
)

// Mode is the authentication mode used for scheduled-task requests.
Expand Down Expand Up @@ -66,8 +68,10 @@ type Validator struct {
verifier *oidc.IDTokenVerifier // nil unless mode == ModeOIDC; backed by go-oidc's single-flight RemoteKeySet
verMu sync.RWMutex // guards verifier and lastRebuild during rotation-recovery rebuilds
lastRebuild time.Time // last verifier rebuild; rate-limits attacker-driven JWKS refetches
keySet *oidc.RemoteKeySet // nil unless mode == ModeOIDC; powered by go-oidc's single-flight cache
jwksURL string // remembered for Warmup and rotation rebuild; empty unless mode == ModeOIDC
issuer string // OIDC issuer URL; empty unless mode == ModeOIDC; needed to rebuild verifier
httpClient *http.Client // hardened client for all JWKS fetches; nil unless mode == ModeOIDC
audiences map[string]struct{}
subjects map[string]struct{}
skew time.Duration
Expand Down Expand Up @@ -167,8 +171,15 @@ func configureOIDC(v *Validator, cfg Config) (*Validator, error) {
v.subjects = subs
v.jwksURL = cfg.JWKSURL
v.issuer = cfg.Issuer
keySet := oidc.NewRemoteKeySet(context.Background(), cfg.JWKSURL)
v.verifier = oidc.NewVerifier(cfg.Issuer, keySet, &oidc.Config{
// All JWKS traffic (warmup probe and go-oidc key fetches) goes
// through the hardened shared client: IMDS/metadata endpoints are
// blocked and dial/TLS/overall timeouts apply. The JWKS URL is
// operator-supplied (SCHEDULED_TASK_OIDC_JWKS_URL), so a
// misconfigured or compromised value must not be able to reach
// internal/metadata endpoints via the default transport.
v.httpClient = httpclient.New()
v.keySet = oidc.NewRemoteKeySet(oidc.ClientContext(context.Background(), v.httpClient), cfg.JWKSURL)
v.verifier = oidc.NewVerifier(cfg.Issuer, v.keySet, &oidc.Config{
// Pin to RS256. Google's tokens are RS256; rejecting anything
// else closes the alg=none / alg=HS256 confusion family.
SupportedSigningAlgs: []string{string(oidc.RS256)},
Expand Down Expand Up @@ -220,9 +231,10 @@ func (v *Validator) Mode() Mode {
}

// warmupTimeout is the fallback deadline for the JWKS warmup probe
// when the caller passes a context without one. http.DefaultClient
// has no timeout, so without this guard a misconfigured / unreachable
// JWKS endpoint would block startup indefinitely.
// when the caller passes a context without one. The hardened client
// has its own overall timeout, but startup should fail the probe much
// faster than that when the JWKS endpoint is misconfigured or
// unreachable.
const warmupTimeout = 5 * time.Second

// Warmup performs a best-effort sanity check on the JWKS endpoint at
Expand Down Expand Up @@ -254,7 +266,7 @@ func (v *Validator) Warmup(ctx context.Context) {
log.Printf("scheduledauth: WARN — JWKS warmup request build failed: %v", err)
return
}
resp, err := http.DefaultClient.Do(req) //nolint:gosec // G704: unsafe operation intentional
resp, err := v.httpClient.Do(req) // #nosec G704 -- jwksURL is operator-supplied config (SCHEDULED_TASK_OIDC_JWKS_URL), not request-tainted user input; v.httpClient is the hardened client from pkg/httpclient that blocks IMDS/link-local endpoints and enforces dial/TLS/overall timeouts
if err != nil {
log.Printf("scheduledauth: WARN — JWKS warmup fetch failed for %s: %v "+
"(validator will retry on first request)", v.jwksURL, err)
Expand Down Expand Up @@ -438,7 +450,7 @@ func (v *Validator) verifyWithRotationRetry(ctx context.Context, rawToken string
return nil, err
}
log.Printf("scheduledauth: oidc signature verification failed; rebuilding key set for rotation retry")
newKS := oidc.NewRemoteKeySet(context.Background(), v.jwksURL)
newKS := oidc.NewRemoteKeySet(oidc.ClientContext(context.Background(), v.httpClient), v.jwksURL)
v.verifier = oidc.NewVerifier(v.issuer, newKS, &oidc.Config{
SupportedSigningAlgs: []string{string(oidc.RS256)},
SkipClientIDCheck: true,
Expand Down
91 changes: 91 additions & 0 deletions internal/server/scheduledauth/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1043,6 +1043,97 @@ func TestWarmup_HitsJWKSEndpoint(t *testing.T) {
}
}

// defaultClientGuard is a RoundTripper installed into http.DefaultClient
// to prove JWKS traffic does NOT flow through the default client
// (SEC-04 regression guard: warmup and key fetches must use the
// hardened client). Any request through it is counted and rejected.
type defaultClientGuard struct {
calls atomic.Int64
}

func (g *defaultClientGuard) RoundTrip(*http.Request) (*http.Response, error) {
g.calls.Add(1)
return nil, errors.New("http.DefaultClient must not be used for JWKS traffic")
}

// swapDefaultClient replaces http.DefaultClient with a guarded client
// for the duration of the test. No tests in this package use
// t.Parallel() (same precedent as the global log-writer swap in
// TestWarmup_LoggedAndNonFatal_OnDeadEndpoint), so mutating the global
// is safe; t.Cleanup restores it.
func swapDefaultClient(t *testing.T) *defaultClientGuard {
t.Helper()
guard := &defaultClientGuard{}
orig := http.DefaultClient
http.DefaultClient = &http.Client{Transport: guard}
t.Cleanup(func() { http.DefaultClient = orig })
return guard
}

// SEC-04 regression: the OIDC validator must construct its own hardened
// HTTP client rather than relying on http.DefaultClient / the default
// transport for JWKS traffic.
func TestNew_OIDC_UsesHardenedHTTPClient(t *testing.T) {
v := newOIDCValidator(t, "http://127.0.0.1:1/jwks")

if v.httpClient == nil {
t.Fatalf("oidc validator must carry a hardened HTTP client")
}
if v.httpClient == http.DefaultClient {
t.Fatalf("oidc validator must not use http.DefaultClient")
}
if v.httpClient.Timeout == 0 {
t.Fatalf("hardened client must have an overall timeout")
}
if v.httpClient.Transport == nil || v.httpClient.Transport == http.DefaultTransport {
t.Fatalf("hardened client must not ride the default transport")
}
}

// SEC-04 regression: Warmup must probe the JWKS endpoint through the
// hardened client, never http.DefaultClient. Pre-fix this fails: the
// guard intercepts the probe and the JWKS server is never reached.
func TestWarmup_BypassesDefaultClient(t *testing.T) {
key := newTestKey(t, "kid-1")
srv := newJWKSServer(t, jwks(key))
v := newOIDCValidator(t, srv.URL)
guard := swapDefaultClient(t)

before := srv.hits.Load()
v.Warmup(context.Background())

if got := guard.calls.Load(); got != 0 {
t.Fatalf("Warmup made %d request(s) through http.DefaultClient; want 0", got)
}
if after := srv.hits.Load(); after <= before {
t.Fatalf("Warmup never reached the JWKS endpoint (before=%d after=%d)", before, after)
}
}

// SEC-04 regression: go-oidc's RemoteKeySet fetch (triggered by token
// verification) must also go through the hardened client supplied via
// oidc.ClientContext. Pre-fix this fails: go-oidc falls back to
// http.DefaultClient, the guard rejects the fetch, and validation
// errors out.
func TestValidate_OIDC_KeyFetchBypassesDefaultClient(t *testing.T) {
key := newTestKey(t, "kid-1")
srv := newJWKSServer(t, jwks(key))
v := newOIDCValidator(t, srv.URL)
guard := swapDefaultClient(t)

tok := signToken(t, key, baseClaims(time.Now(),
testSchedulerSubject,
"https://api.example.com",
"https://accounts.example.com"))

if err := v.Validate(context.Background(), "Bearer "+tok); err != nil {
t.Fatalf("expected valid token, got: %v", err)
}
if got := guard.calls.Load(); got != 0 {
t.Fatalf("key fetch made %d request(s) through http.DefaultClient; want 0", got)
}
}

func TestValidateJWKSBody_RequiresKeysArray(t *testing.T) {
tests := []struct {
name string
Expand Down
68 changes: 68 additions & 0 deletions pkg/httpclient/httpclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Package httpclient provides a hardened HTTP client for outbound
// requests. It blocks connections to the cloud Instance Metadata
// Service (IMDS) endpoints to prevent SSRF attacks that could leak
// cloud credentials, and applies sane dial/TLS/overall timeouts so a
// misbehaving endpoint cannot hang a caller indefinitely.
//
// This is the single shared implementation; the Azure provider's
// internal httpclient package delegates here so every module uses the
// same hardening.
package httpclient

import (
"context"
"fmt"
"net"
"net/http"
"time"
)

// Timeouts applied by New. Exported indirectly via the constructed
// client; named here so the values are not magic numbers.
const (
dialTimeout = 10 * time.Second
keepAliveInterval = 30 * time.Second
tlsHandshakeTimeout = 10 * time.Second
requestTimeout = 30 * time.Second
)

// imdsAddresses are the well-known metadata service addresses that must never
// be reachable from application-level HTTP clients.
var imdsAddresses = map[string]bool{
"169.254.169.254": true, // AWS/Azure/GCP link-local IMDS (IPv4)
"fd00:ec2::254": true, // AWS IMDS (IPv6)
}

// blockIMDSDialer wraps net.Dialer and rejects connections to IMDS addresses.
type blockIMDSDialer struct {
inner net.Dialer
}

func (d *blockIMDSDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if imdsAddresses[host] {
return nil, fmt.Errorf("connection to metadata endpoint %s is blocked", host)
}
return d.inner.DialContext(ctx, network, addr)
}

// New returns an *http.Client with a 30-second timeout and IMDS blocking.
func New() *http.Client {
dialer := &blockIMDSDialer{
inner: net.Dialer{
Timeout: dialTimeout,
KeepAlive: keepAliveInterval,
},
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: tlsHandshakeTimeout,
}
return &http.Client{
Timeout: requestTimeout,
Transport: transport,
}
}
73 changes: 73 additions & 0 deletions pkg/httpclient/httpclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package httpclient

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

// New must hand back a dedicated hardened client, never the process-wide
// default client/transport (SEC-04 regression guard).
func TestNew_NotDefaultClient(t *testing.T) {
c := New()

if c == http.DefaultClient {
t.Fatalf("New() must not return http.DefaultClient")
}
if c.Timeout == 0 {
t.Fatalf("New() must set an overall request timeout")
}
if c.Transport == nil || c.Transport == http.DefaultTransport {
t.Fatalf("New() must install a dedicated hardened transport")
}
}

func TestNew_BlocksIMDS(t *testing.T) {
tests := []struct {
name string
url string
}{
{name: "ipv4 link-local IMDS", url: "http://169.254.169.254/latest/meta-data/"},
{name: "ipv6 AWS IMDS", url: "http://[fd00:ec2::254]/latest/meta-data/"},
}

c := New()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, tt.url, nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
resp, err := c.Do(req)
if err == nil {
resp.Body.Close()
t.Fatalf("request to %s must be blocked", tt.url)
}
if !strings.Contains(err.Error(), "blocked") {
t.Fatalf("expected IMDS-blocked error, got: %v", err)
}
})
}
}

func TestNew_AllowsRegularEndpoints(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)

req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, nil)
if err != nil {
t.Fatalf("build request: %v", err)
}
resp, err := New().Do(req)
if err != nil {
t.Fatalf("request to non-IMDS endpoint failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
}
48 changes: 7 additions & 41 deletions providers/azure/internal/httpclient/httpclient.go
Original file line number Diff line number Diff line change
@@ -1,53 +1,19 @@
// Package httpclient provides a hardened HTTP client for Azure provider use.
// It blocks requests to the Instance Metadata Service (IMDS) endpoints to
// prevent SSRF attacks that could leak cloud credentials.
//
// The implementation lives in the shared pkg module so the root module
// (which cannot import this internal package across the module boundary)
// uses the exact same hardening; this package only delegates.
package httpclient

import (
"context"
"fmt"
"net"
"net/http"
"time"
)

// imdsAddresses are the well-known metadata service addresses that must never
// be reachable from application-level HTTP clients.
var imdsAddresses = map[string]bool{
"169.254.169.254": true, // AWS/Azure/GCP link-local IMDS (IPv4)
"fd00:ec2::254": true, // AWS IMDS (IPv6)
}

// blockIMDSDialer wraps net.Dialer and rejects connections to IMDS addresses.
type blockIMDSDialer struct {
inner net.Dialer
}

func (d *blockIMDSDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
if imdsAddresses[host] {
return nil, fmt.Errorf("connection to metadata endpoint %s is blocked", host)
}
return d.inner.DialContext(ctx, network, addr)
}
"github.com/LeanerCloud/CUDly/pkg/httpclient"
)

// New returns an *http.Client with a 30-second timeout and IMDS blocking.
func New() *http.Client {
dialer := &blockIMDSDialer{
inner: net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
},
}
transport := &http.Transport{
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 10 * time.Second,
}
return &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
}
return httpclient.New()
}
Loading