Skip to content

Commit 2c100fb

Browse files
committed
fix(scheduledauth): use hardened HTTP client for JWKS warmup and fetch
The scheduled-task OIDC validator issued its JWKS warmup probe via http.DefaultClient and let go-oidc's RemoteKeySet fetch keys over the default transport. The JWKS URL is operator-supplied (SCHEDULED_TASK_OIDC_JWKS_URL), so a misconfigured or compromised value could reach internal/metadata endpoints, bypassing the IMDS-blocking hardening the Azure provider clients already use. Move the hardened client (IMDS blocking, dial/TLS/overall timeouts) from providers/azure/internal/httpclient to the shared pkg/httpclient package, since the Azure-internal package is unimportable across the module boundary; the Azure package now delegates to it so there is a single implementation. The validator constructs this client once in configureOIDC, uses it for the warmup probe, and supplies it to go-oidc via oidc.ClientContext so key fetches and rotation refreshes ride the same hardened transport. Regression tests guard all paths: the constructed client must not be http.DefaultClient and must carry a timeout and dedicated transport; Warmup and token-verification key fetches are run with a guarded http.DefaultClient that fails the test if used (both fail pre-fix); pkg/httpclient gets direct IMDS-blocking coverage (IPv4 + IPv6). Closes #1145
1 parent 4108e51 commit 2c100fb

5 files changed

Lines changed: 255 additions & 55 deletions

File tree

‎internal/server/scheduledauth/validator.go‎

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
"time"
1515

1616
"github.com/coreos/go-oidc/v3/oidc"
17+
18+
"github.com/LeanerCloud/CUDly/pkg/httpclient"
1719
)
1820

1921
// Mode is the authentication mode used for scheduled-task requests.
@@ -61,15 +63,16 @@ type Config struct {
6163

6264
// Validator authenticates inbound requests against the configured mode.
6365
type Validator struct {
64-
mode Mode
65-
verifier *oidc.IDTokenVerifier // nil unless mode == ModeOIDC
66-
keySet *oidc.RemoteKeySet // nil unless mode == ModeOIDC; powered by go-oidc's single-flight cache
67-
jwksURL string // remembered for Warmup; empty unless mode == ModeOIDC
68-
audiences map[string]struct{}
69-
subjects map[string]struct{}
70-
skew time.Duration
71-
bearer []byte
72-
now func() time.Time // pluggable for tests
66+
mode Mode
67+
verifier *oidc.IDTokenVerifier // nil unless mode == ModeOIDC
68+
keySet *oidc.RemoteKeySet // nil unless mode == ModeOIDC; powered by go-oidc's single-flight cache
69+
jwksURL string // remembered for Warmup; empty unless mode == ModeOIDC
70+
httpClient *http.Client // hardened client for all JWKS fetches; nil unless mode == ModeOIDC
71+
audiences map[string]struct{}
72+
subjects map[string]struct{}
73+
skew time.Duration
74+
bearer []byte
75+
now func() time.Time // pluggable for tests
7376
}
7477

7578
// claims captures the timestamp claims we need beyond what go-oidc's
@@ -163,7 +166,14 @@ func configureOIDC(v *Validator, cfg Config) (*Validator, error) {
163166
v.audiences = auds
164167
v.subjects = subs
165168
v.jwksURL = cfg.JWKSURL
166-
v.keySet = oidc.NewRemoteKeySet(context.Background(), cfg.JWKSURL)
169+
// All JWKS traffic (warmup probe and go-oidc key fetches) goes
170+
// through the hardened shared client: IMDS/metadata endpoints are
171+
// blocked and dial/TLS/overall timeouts apply. The JWKS URL is
172+
// operator-supplied (SCHEDULED_TASK_OIDC_JWKS_URL), so a
173+
// misconfigured or compromised value must not be able to reach
174+
// internal/metadata endpoints via the default transport.
175+
v.httpClient = httpclient.New()
176+
v.keySet = oidc.NewRemoteKeySet(oidc.ClientContext(context.Background(), v.httpClient), cfg.JWKSURL)
167177
v.verifier = oidc.NewVerifier(cfg.Issuer, v.keySet, &oidc.Config{
168178
// Pin to RS256. Google's tokens are RS256; rejecting anything
169179
// else closes the alg=none / alg=HS256 confusion family.
@@ -216,9 +226,10 @@ func (v *Validator) Mode() Mode {
216226
}
217227

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

224235
// Warmup performs a best-effort sanity check on the JWKS endpoint at
@@ -250,7 +261,7 @@ func (v *Validator) Warmup(ctx context.Context) {
250261
log.Printf("scheduledauth: WARN — JWKS warmup request build failed: %v", err)
251262
return
252263
}
253-
resp, err := http.DefaultClient.Do(req)
264+
resp, err := v.httpClient.Do(req)
254265
if err != nil {
255266
log.Printf("scheduledauth: WARN — JWKS warmup fetch failed for %s: %v "+
256267
"(validator will retry on first request)", v.jwksURL, err)

‎internal/server/scheduledauth/validator_test.go‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,97 @@ func TestWarmup_HitsJWKSEndpoint(t *testing.T) {
723723
}
724724
}
725725

726+
// defaultClientGuard is a RoundTripper installed into http.DefaultClient
727+
// to prove JWKS traffic does NOT flow through the default client
728+
// (SEC-04 regression guard: warmup and key fetches must use the
729+
// hardened client). Any request through it is counted and rejected.
730+
type defaultClientGuard struct {
731+
calls atomic.Int64
732+
}
733+
734+
func (g *defaultClientGuard) RoundTrip(*http.Request) (*http.Response, error) {
735+
g.calls.Add(1)
736+
return nil, errors.New("http.DefaultClient must not be used for JWKS traffic")
737+
}
738+
739+
// swapDefaultClient replaces http.DefaultClient with a guarded client
740+
// for the duration of the test. No tests in this package use
741+
// t.Parallel() (same precedent as the global log-writer swap in
742+
// TestWarmup_LoggedAndNonFatal_OnDeadEndpoint), so mutating the global
743+
// is safe; t.Cleanup restores it.
744+
func swapDefaultClient(t *testing.T) *defaultClientGuard {
745+
t.Helper()
746+
guard := &defaultClientGuard{}
747+
orig := http.DefaultClient
748+
http.DefaultClient = &http.Client{Transport: guard}
749+
t.Cleanup(func() { http.DefaultClient = orig })
750+
return guard
751+
}
752+
753+
// SEC-04 regression: the OIDC validator must construct its own hardened
754+
// HTTP client rather than relying on http.DefaultClient / the default
755+
// transport for JWKS traffic.
756+
func TestNew_OIDC_UsesHardenedHTTPClient(t *testing.T) {
757+
v := newOIDCValidator(t, "http://127.0.0.1:1/jwks")
758+
759+
if v.httpClient == nil {
760+
t.Fatalf("oidc validator must carry a hardened HTTP client")
761+
}
762+
if v.httpClient == http.DefaultClient {
763+
t.Fatalf("oidc validator must not use http.DefaultClient")
764+
}
765+
if v.httpClient.Timeout == 0 {
766+
t.Fatalf("hardened client must have an overall timeout")
767+
}
768+
if v.httpClient.Transport == nil || v.httpClient.Transport == http.DefaultTransport {
769+
t.Fatalf("hardened client must not ride the default transport")
770+
}
771+
}
772+
773+
// SEC-04 regression: Warmup must probe the JWKS endpoint through the
774+
// hardened client, never http.DefaultClient. Pre-fix this fails: the
775+
// guard intercepts the probe and the JWKS server is never reached.
776+
func TestWarmup_BypassesDefaultClient(t *testing.T) {
777+
key := newTestKey(t, "kid-1")
778+
srv := newJWKSServer(t, jwks(key))
779+
v := newOIDCValidator(t, srv.URL)
780+
guard := swapDefaultClient(t)
781+
782+
before := srv.hits.Load()
783+
v.Warmup(context.Background())
784+
785+
if got := guard.calls.Load(); got != 0 {
786+
t.Fatalf("Warmup made %d request(s) through http.DefaultClient; want 0", got)
787+
}
788+
if after := srv.hits.Load(); after <= before {
789+
t.Fatalf("Warmup never reached the JWKS endpoint (before=%d after=%d)", before, after)
790+
}
791+
}
792+
793+
// SEC-04 regression: go-oidc's RemoteKeySet fetch (triggered by token
794+
// verification) must also go through the hardened client supplied via
795+
// oidc.ClientContext. Pre-fix this fails: go-oidc falls back to
796+
// http.DefaultClient, the guard rejects the fetch, and validation
797+
// errors out.
798+
func TestValidate_OIDC_KeyFetchBypassesDefaultClient(t *testing.T) {
799+
key := newTestKey(t, "kid-1")
800+
srv := newJWKSServer(t, jwks(key))
801+
v := newOIDCValidator(t, srv.URL)
802+
guard := swapDefaultClient(t)
803+
804+
tok := signToken(t, key, baseClaims(time.Now(),
805+
testSchedulerSubject,
806+
"https://api.example.com",
807+
"https://accounts.example.com"))
808+
809+
if err := v.Validate(context.Background(), "Bearer "+tok); err != nil {
810+
t.Fatalf("expected valid token, got: %v", err)
811+
}
812+
if got := guard.calls.Load(); got != 0 {
813+
t.Fatalf("key fetch made %d request(s) through http.DefaultClient; want 0", got)
814+
}
815+
}
816+
726817
func TestValidateJWKSBody_RequiresKeysArray(t *testing.T) {
727818
tests := []struct {
728819
name string

‎pkg/httpclient/httpclient.go‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Package httpclient provides a hardened HTTP client for outbound
2+
// requests. It blocks connections to the cloud Instance Metadata
3+
// Service (IMDS) endpoints to prevent SSRF attacks that could leak
4+
// cloud credentials, and applies sane dial/TLS/overall timeouts so a
5+
// misbehaving endpoint cannot hang a caller indefinitely.
6+
//
7+
// This is the single shared implementation; the Azure provider's
8+
// internal httpclient package delegates here so every module uses the
9+
// same hardening.
10+
package httpclient
11+
12+
import (
13+
"context"
14+
"fmt"
15+
"net"
16+
"net/http"
17+
"time"
18+
)
19+
20+
// Timeouts applied by New. Exported indirectly via the constructed
21+
// client; named here so the values are not magic numbers.
22+
const (
23+
dialTimeout = 10 * time.Second
24+
keepAliveInterval = 30 * time.Second
25+
tlsHandshakeTimeout = 10 * time.Second
26+
requestTimeout = 30 * time.Second
27+
)
28+
29+
// imdsAddresses are the well-known metadata service addresses that must never
30+
// be reachable from application-level HTTP clients.
31+
var imdsAddresses = map[string]bool{
32+
"169.254.169.254": true, // AWS/Azure/GCP link-local IMDS (IPv4)
33+
"fd00:ec2::254": true, // AWS IMDS (IPv6)
34+
}
35+
36+
// blockIMDSDialer wraps net.Dialer and rejects connections to IMDS addresses.
37+
type blockIMDSDialer struct {
38+
inner net.Dialer
39+
}
40+
41+
func (d *blockIMDSDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
42+
host, _, err := net.SplitHostPort(addr)
43+
if err != nil {
44+
host = addr
45+
}
46+
if imdsAddresses[host] {
47+
return nil, fmt.Errorf("connection to metadata endpoint %s is blocked", host)
48+
}
49+
return d.inner.DialContext(ctx, network, addr)
50+
}
51+
52+
// New returns an *http.Client with a 30-second timeout and IMDS blocking.
53+
func New() *http.Client {
54+
dialer := &blockIMDSDialer{
55+
inner: net.Dialer{
56+
Timeout: dialTimeout,
57+
KeepAlive: keepAliveInterval,
58+
},
59+
}
60+
transport := &http.Transport{
61+
DialContext: dialer.DialContext,
62+
TLSHandshakeTimeout: tlsHandshakeTimeout,
63+
}
64+
return &http.Client{
65+
Timeout: requestTimeout,
66+
Transport: transport,
67+
}
68+
}

‎pkg/httpclient/httpclient_test.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package httpclient
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// New must hand back a dedicated hardened client, never the process-wide
11+
// default client/transport (SEC-04 regression guard).
12+
func TestNew_NotDefaultClient(t *testing.T) {
13+
c := New()
14+
15+
if c == http.DefaultClient {
16+
t.Fatalf("New() must not return http.DefaultClient")
17+
}
18+
if c.Timeout == 0 {
19+
t.Fatalf("New() must set an overall request timeout")
20+
}
21+
if c.Transport == nil || c.Transport == http.DefaultTransport {
22+
t.Fatalf("New() must install a dedicated hardened transport")
23+
}
24+
}
25+
26+
func TestNew_BlocksIMDS(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
url string
30+
}{
31+
{name: "ipv4 link-local IMDS", url: "http://169.254.169.254/latest/meta-data/"},
32+
{name: "ipv6 AWS IMDS", url: "http://[fd00:ec2::254]/latest/meta-data/"},
33+
}
34+
35+
c := New()
36+
for _, tt := range tests {
37+
t.Run(tt.name, func(t *testing.T) {
38+
resp, err := c.Get(tt.url)
39+
if err == nil {
40+
resp.Body.Close()
41+
t.Fatalf("request to %s must be blocked", tt.url)
42+
}
43+
if !strings.Contains(err.Error(), "blocked") {
44+
t.Fatalf("expected IMDS-blocked error, got: %v", err)
45+
}
46+
})
47+
}
48+
}
49+
50+
func TestNew_AllowsRegularEndpoints(t *testing.T) {
51+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
52+
w.WriteHeader(http.StatusOK)
53+
}))
54+
t.Cleanup(srv.Close)
55+
56+
resp, err := New().Get(srv.URL)
57+
if err != nil {
58+
t.Fatalf("request to non-IMDS endpoint failed: %v", err)
59+
}
60+
defer resp.Body.Close()
61+
if resp.StatusCode != http.StatusOK {
62+
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
63+
}
64+
}
Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,19 @@
11
// Package httpclient provides a hardened HTTP client for Azure provider use.
22
// It blocks requests to the Instance Metadata Service (IMDS) endpoints to
33
// prevent SSRF attacks that could leak cloud credentials.
4+
//
5+
// The implementation lives in the shared pkg module so the root module
6+
// (which cannot import this internal package across the module boundary)
7+
// uses the exact same hardening; this package only delegates.
48
package httpclient
59

610
import (
7-
"context"
8-
"fmt"
9-
"net"
1011
"net/http"
11-
"time"
12-
)
13-
14-
// imdsAddresses are the well-known metadata service addresses that must never
15-
// be reachable from application-level HTTP clients.
16-
var imdsAddresses = map[string]bool{
17-
"169.254.169.254": true, // AWS/Azure/GCP link-local IMDS (IPv4)
18-
"fd00:ec2::254": true, // AWS IMDS (IPv6)
19-
}
2012

21-
// blockIMDSDialer wraps net.Dialer and rejects connections to IMDS addresses.
22-
type blockIMDSDialer struct {
23-
inner net.Dialer
24-
}
25-
26-
func (d *blockIMDSDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
27-
host, _, err := net.SplitHostPort(addr)
28-
if err != nil {
29-
host = addr
30-
}
31-
if imdsAddresses[host] {
32-
return nil, fmt.Errorf("connection to metadata endpoint %s is blocked", host)
33-
}
34-
return d.inner.DialContext(ctx, network, addr)
35-
}
13+
"github.com/LeanerCloud/CUDly/pkg/httpclient"
14+
)
3615

3716
// New returns an *http.Client with a 30-second timeout and IMDS blocking.
3817
func New() *http.Client {
39-
dialer := &blockIMDSDialer{
40-
inner: net.Dialer{
41-
Timeout: 10 * time.Second,
42-
KeepAlive: 30 * time.Second,
43-
},
44-
}
45-
transport := &http.Transport{
46-
DialContext: dialer.DialContext,
47-
TLSHandshakeTimeout: 10 * time.Second,
48-
}
49-
return &http.Client{
50-
Timeout: 30 * time.Second,
51-
Transport: transport,
52-
}
18+
return httpclient.New()
5319
}

0 commit comments

Comments
 (0)