Skip to content

Commit 7a9f4b0

Browse files
authored
Merge commit from fork
* fix(auth): validate bearer realm host to prevent credential exfiltration A malicious registry can set the WWW-Authenticate Bearer realm to an attacker-controlled URL. Without validation, ORAS would send the user's plaintext credentials or OAuth2 tokens to that arbitrary host. Validate that the realm URL host matches the registry host before making any credential-bearing request. Add TrustedRealmHosts to Client for deployments that legitimately use a separate token service host. Fixes GHSA-28r5-37g7-p6mp Signed-off-by: Terry Howe <terrylhowe@gmail.com> * fix(auth): reject unsafe bearer realm URLs Replace the prior strict same-host realm check with a narrower defensive validation that rejects only obviously unsafe realm destinations: - schemes other than http or https - http realms when the registry was contacted over https (TLS downgrade) - hosts that are IP literals in loopback, link-local, private, or unspecified ranges (e.g. cloud instance metadata services such as 169.254.169.254) Cross-host realms on public DNS names are preserved so that distribution-spec-conformant deployments such as Docker Hub (registry-1.docker.io -> auth.docker.io) continue to work. The IP-literal check is skipped when the realm hostname matches the registry hostname so loopback and in-cluster deployments are unaffected. Refs: GHSA-xf85-363p-868w Signed-off-by: Terry Howe <terrylhowe@gmail.com> --------- Signed-off-by: Terry Howe <terrylhowe@gmail.com>
1 parent d593d50 commit 7a9f4b0

3 files changed

Lines changed: 87 additions & 5 deletions

File tree

registry/remote/auth/client.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"errors"
2424
"fmt"
2525
"io"
26+
"net"
2627
"net/http"
2728
"net/url"
2829
"strings"
@@ -156,6 +157,49 @@ func (c *Client) cache() Cache {
156157
return c.Cache
157158
}
158159

160+
// validateRealm rejects bearer token realm URLs that would have the client
161+
// forward credentials to obviously unsafe destinations:
162+
//
163+
// - schemes other than http or https,
164+
// - http realms when the registry was contacted over https (TLS downgrade),
165+
// - hosts that are IP literals in loopback, link-local, private, or
166+
// unspecified ranges (e.g. cloud instance metadata services such as
167+
// 169.254.169.254).
168+
//
169+
// Cross-host realms with a public hostname are permitted, because the
170+
// distribution spec allows a separate token endpoint (e.g. Docker Hub's
171+
// auth.docker.io). When the registry itself is reached at the same hostname
172+
// as the realm, the IP-literal check is skipped so loopback and in-cluster
173+
// deployments continue to work.
174+
func validateRealm(realm string, registryURL *url.URL) error {
175+
if realm == "" {
176+
return nil
177+
}
178+
realmURL, err := url.Parse(realm)
179+
if err != nil {
180+
return fmt.Errorf("failed to parse bearer realm %q: %w", realm, err)
181+
}
182+
switch realmURL.Scheme {
183+
case "https":
184+
// always allowed
185+
case "http":
186+
if registryURL != nil && registryURL.Scheme == "https" {
187+
return fmt.Errorf("bearer realm %q uses http but registry was contacted over https", realm)
188+
}
189+
default:
190+
return fmt.Errorf("bearer realm %q uses unsupported scheme %q", realm, realmURL.Scheme)
191+
}
192+
if ip := net.ParseIP(realmURL.Hostname()); ip != nil {
193+
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
194+
ip.IsPrivate() || ip.IsUnspecified() {
195+
if registryURL == nil || realmURL.Hostname() != registryURL.Hostname() {
196+
return fmt.Errorf("bearer realm host %q is a loopback, link-local, private, or unspecified address", realmURL.Hostname())
197+
}
198+
}
199+
}
200+
return nil
201+
}
202+
159203
// SetUserAgent sets the user agent for all out-going requests.
160204
func (c *Client) SetUserAgent(userAgent string) {
161205
if c.Header == nil {
@@ -257,6 +301,9 @@ func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
257301

258302
// attempt with credentials
259303
realm := params["realm"]
304+
if err := validateRealm(realm, originalReq.URL); err != nil {
305+
return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err)
306+
}
260307
service := params["service"]
261308
token, err := cache.Set(ctx, host, SchemeBearer, key, func(ctx context.Context) (string, error) {
262309
return c.fetchBearerToken(ctx, host, realm, service, scopes)

registry/remote/auth/client_test.go

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,7 +3743,7 @@ func TestClient_StaticCredential_basicAuth(t *testing.T) {
37433743
testPassword := "password"
37443744

37453745
// create a test server
3746-
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3746+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37473747
path := r.URL.Path
37483748
if r.Method != http.MethodGet {
37493749
w.WriteHeader(http.StatusNotFound)
@@ -3815,7 +3815,7 @@ func TestClient_StaticCredential_withAccessToken(t *testing.T) {
38153815
defer as.Close()
38163816

38173817
// create a test server
3818-
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3818+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
38193819
path := r.URL.Path
38203820
if r.Method != http.MethodGet {
38213821
w.WriteHeader(http.StatusNotFound)
@@ -3909,7 +3909,7 @@ func TestClient_StaticCredential_withRefreshToken(t *testing.T) {
39093909
defer as.Close()
39103910

39113911
// create a test server
3912-
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3912+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39133913
path := r.URL.Path
39143914
if r.Method != http.MethodGet {
39153915
w.WriteHeader(http.StatusNotFound)
@@ -3976,3 +3976,39 @@ func TestClient_fetchBasicAuth(t *testing.T) {
39763976
t.Errorf("incorrect error: %v, expected %v", err, ErrBasicCredentialNotFound)
39773977
}
39783978
}
3979+
3980+
func Test_validateRealm(t *testing.T) {
3981+
registryHTTPS, _ := url.Parse("https://registry.example.com/v2/")
3982+
registryHTTP, _ := url.Parse("http://registry.example.com/v2/")
3983+
registryLoopback, _ := url.Parse("http://127.0.0.1:5000/v2/")
3984+
3985+
tests := []struct {
3986+
name string
3987+
realm string
3988+
registry *url.URL
3989+
wantErr bool
3990+
}{
3991+
{"empty realm is allowed", "", registryHTTPS, false},
3992+
{"https realm is allowed", "https://auth.example.com/token", registryHTTPS, false},
3993+
{"https cross-host realm is allowed", "https://other.example.com/token", registryHTTPS, false},
3994+
{"http realm against plain-http registry is allowed", "http://auth.example.com/token", registryHTTP, false},
3995+
{"http realm against https registry is rejected (TLS downgrade)", "http://auth.example.com/token", registryHTTPS, true},
3996+
{"non-http scheme is rejected", "file:///etc/passwd", registryHTTPS, true},
3997+
{"ftp scheme is rejected", "ftp://auth.example.com/token", registryHTTPS, true},
3998+
{"IMDS IPv4 is rejected", "http://169.254.169.254/latest/meta-data/", registryHTTP, true},
3999+
{"loopback IPv4 is rejected when registry is on a public host", "http://127.0.0.1:9090/token", registryHTTPS, true},
4000+
{"loopback IPv6 is rejected when registry is on a public host", "http://[::1]:9090/token", registryHTTPS, true},
4001+
{"private RFC1918 is rejected when registry is on a public host", "http://10.0.0.5/token", registryHTTP, true},
4002+
{"unspecified address is rejected", "http://0.0.0.0/token", registryHTTP, true},
4003+
{"loopback realm is allowed when registry shares the loopback hostname", "http://127.0.0.1:9090/token", registryLoopback, false},
4004+
{"unparseable realm is rejected", "://bad-url", registryHTTPS, true},
4005+
}
4006+
for _, tt := range tests {
4007+
t.Run(tt.name, func(t *testing.T) {
4008+
err := validateRealm(tt.realm, tt.registry)
4009+
if (err != nil) != tt.wantErr {
4010+
t.Errorf("validateRealm(%q, %v) error = %v, wantErr %v", tt.realm, tt.registry, err, tt.wantErr)
4011+
}
4012+
})
4013+
}
4014+
}

registry/remote/auth/example_test.go

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ func TestMain(m *testing.M) {
8484
defer as.Close()
8585

8686
// create a test server
87-
ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8888
path := r.URL.Path
8989
if r.Method != http.MethodGet {
9090
w.WriteHeader(http.StatusNotFound)
@@ -134,7 +134,6 @@ func TestMain(m *testing.M) {
134134
clientConfigTargetURL = fmt.Sprintf("%s/clientConfig", host)
135135
accessTokenTargetURL = fmt.Sprintf("%s/accessToken", host)
136136
refreshTokenTargetURL = fmt.Sprintf("%s/refreshToken", host)
137-
http.DefaultClient = ts.Client()
138137

139138
os.Exit(m.Run())
140139
}

0 commit comments

Comments
 (0)