Visitar URL original
transport: block redirects from token server to private/link-local ad… · google/go-containerregistry@a70d75a · GitHub
Skip to content

Commit a70d75a

Browse files
authored
transport: block redirects from token server to private/link-local addresses (SSRF fix) (#2292)
* transport: block redirects from token server to private addresses refreshOauth and refreshBasic constructed http.Client without a CheckRedirect handler, allowing a malicious token server to issue an HTTP redirect to an internal address (e.g. 169.254.169.254) that would pass the initial validateRealmURL check but still reach private services. Add realmRedirectCheck, which applies the same scheme and private-IP restrictions as validateRealmURL to every redirect hop during a token-fetch request. Wire it as CheckRedirect on both clients. Add TestTokenServerRedirectSSRF to confirm the redirect is rejected. * transport: address review feedback - Shorten realmRedirectCheck doc comment. - Extract allowInsecure local in refreshOauth and refreshBasic. - Drop redundant inline comment in TestTokenServerRedirectSSRF. * transport: rename unused handler param to satisfy revive linter
1 parent a4da0ed commit a70d75a

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

‎pkg/v1/remote/transport/bearer.go‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ func fromChallenge(reg name.Registry, auth authn.Authenticator, t http.RoundTrip
109109
}, nil
110110
}
111111

112+
// realmRedirectCheck mimics the default http.Client redirect policy but also
113+
// validates each redirect URL with validateRealmURL.
114+
func realmRedirectCheck(insecure bool) func(*http.Request, []*http.Request) error {
115+
return func(req *http.Request, via []*http.Request) error {
116+
if len(via) >= 10 {
117+
return fmt.Errorf("stopped after 10 redirects")
118+
}
119+
if err := validateRealmURL(req.URL.String(), insecure); err != nil {
120+
return fmt.Errorf("refusing token-server redirect to %q: %w", req.URL, err)
121+
}
122+
return nil
123+
}
124+
}
125+
112126
// validateRealmURL returns an error if the realm URL uses a disallowed scheme
113127
// or resolves to a private / link-local IP address. This prevents a crafted
114128
// WWW-Authenticate header from redirecting token fetches to internal services.
@@ -378,7 +392,8 @@ func (bt *bearerTransport) refreshOauth(ctx context.Context) ([]byte, error) {
378392
v.Set("access_type", "offline")
379393
}
380394

381-
client := http.Client{Transport: bt.inner}
395+
allowInsecure := bt.scheme == "http"
396+
client := http.Client{Transport: bt.inner, CheckRedirect: realmRedirectCheck(allowInsecure)}
382397
req, err := http.NewRequest(http.MethodPost, u.String(), strings.NewReader(v.Encode()))
383398
if err != nil {
384399
return nil, err
@@ -415,7 +430,8 @@ func (bt *bearerTransport) refreshBasic(ctx context.Context) ([]byte, error) {
415430
auth: bt.basic,
416431
target: u.Host,
417432
}
418-
client := http.Client{Transport: b}
433+
allowInsecure := bt.scheme == "http"
434+
client := http.Client{Transport: b, CheckRedirect: realmRedirectCheck(allowInsecure)}
419435

420436
v := u.Query()
421437
bt.mx.RLock()

‎pkg/v1/remote/transport/bearer_test.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,42 @@ func TestInsufficientScope(t *testing.T) {
560560
}
561561
}
562562

563+
// TestTokenServerRedirectSSRF verifies that a malicious token server cannot
564+
// redirect token-fetch requests to private/loopback addresses, bypassing the
565+
// initial validateRealmURL check.
566+
func TestTokenServerRedirectSSRF(t *testing.T) {
567+
// internalServer simulates an internal service that should never be reached.
568+
internalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
569+
fmt.Fprintf(w, `{"token": "should-not-reach-this"}`)
570+
}))
571+
defer internalServer.Close()
572+
573+
// tokenServer issues a redirect to the internalServer on every request.
574+
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
575+
http.Redirect(w, r, internalServer.URL+"/token", http.StatusFound)
576+
}))
577+
defer tokenServer.Close()
578+
579+
registry, err := name.NewRegistry("registry.example.com", name.WeakValidation)
580+
if err != nil {
581+
t.Fatal(err)
582+
}
583+
584+
bt := &bearerTransport{
585+
inner: http.DefaultTransport,
586+
basic: &authn.Basic{Username: "foo", Password: "bar"},
587+
registry: registry,
588+
realm: tokenServer.URL + "/token",
589+
scopes: []string{"repo:example/image:pull"},
590+
service: "registry.example.com",
591+
scheme: "http",
592+
}
593+
594+
if err := bt.refresh(context.Background()); err == nil {
595+
t.Error("refresh() should have been rejected when token server redirects to loopback address")
596+
}
597+
}
598+
563599
func TestValidateRealmURLUnspecified(t *testing.T) {
564600
// 0.0.0.0 and :: resolve to localhost on most OSes.
565601
// They should be blocked like other private addresses.

0 commit comments

Comments
 (0)