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
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,8 @@ Configure authentication for private upstream registries. The same authenticatio

OCI registries that return a Bearer challenge from a `/v2/{repository}/…` endpoint are handled automatically. The proxy discovers the token realm from `WWW-Authenticate`, applies any configured credentials for the token URL, and reuses the scoped token until shortly before it expires.

A token fetched with credentials configured for the token URL is only fetched for, and sent to, a registry that is trusted with it: one with configured credentials of its own, or one on the same site (registrable domain) as the token service, as `registry-1.docker.io` is with `auth.docker.io`. Any other host that answers with a challenge naming that token service gets its 401 back, so it cannot obtain a token minted from your credentials. Tokens from a token service with no configured credentials are anonymous and are fetched for any registry, as before. If your registry and its token service are on different sites, configure credentials for the registry URL as well.

### Bearer Token

Used by npm, GitHub Package Registry, and many other registries:
Expand Down
173 changes: 173 additions & 0 deletions internal/httpclient/token_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package httpclient

import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
)

// tokenService issues "secret-token" only to requests carrying the configured
// credentials, and "anonymous-token" otherwise.
func tokenService(t *testing.T, issued *atomic.Int32) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
issued.Add(1)
token := "anonymous-token"
if r.Header.Get("Authorization") == "Basic Y29ycDpwYXNz" {
token = "secret-token"
}
_, _ = io.WriteString(w, `{"token":"`+token+`","expires_in":3600}`)
}))
t.Cleanup(srv.Close)
return srv
}

// challengingRegistry answers every request without a bearer token with a
// challenge naming realm, and records the Authorization headers it receives.
func challengingRegistry(t *testing.T, realm string, received *[]string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
*received = append(*received, auth)
if !strings.HasPrefix(auth, "Bearer ") {
w.Header().Set("WWW-Authenticate", `Bearer realm="`+realm+`/token",service="registry.corp",scope="repository:corp/private:pull"`)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
_, _ = io.WriteString(w, "blob")
}))
t.Cleanup(srv.Close)
return srv
}

func credentialsFor(prefix string) AuthFunc {
return func(rawURL string) (string, string) {
if strings.HasPrefix(rawURL, prefix) {
return "Authorization", "Basic Y29ycDpwYXNz"
}
return "", ""
}
}

func get(t *testing.T, client *http.Client, rawURL string) int {
t.Helper()
resp, err := client.Get(rawURL)
if err != nil {
t.Fatalf("GET %s: %v", rawURL, err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return resp.StatusCode
}

// TestTransportDoesNotSendCredentialedTokenToAnotherSite is a regression test
// for a credential leak. Any host the proxy fetches from, such as one named in
// a Composer dist URL or a Helm index, could answer with a challenge naming a
// token service the proxy has credentials for. The proxy fetched a token with
// those credentials and sent it to that host.
func TestTransportDoesNotSendCredentialedTokenToAnotherSite(t *testing.T) {
var issued atomic.Int32
realm := tokenService(t, &issued)
var received []string
evil := challengingRegistry(t, realm.URL, &received)

client := &http.Client{Transport: NewTransport(http.DefaultTransport, credentialsFor(realm.URL))}
if status := get(t, client, evil.URL+"/v2/corp/private/blobs/sha256:abc"); status != http.StatusUnauthorized {
t.Errorf("status = %d, want the challenge passed back as 401", status)
}
for _, auth := range received {
if auth != "" {
t.Errorf("the challenging host received Authorization %q", auth)
}
}
if n := issued.Load(); n != 0 {
t.Errorf("token service was asked %d times on the challenging host's behalf, want 0", n)
}
}

// TestTransportDoesNotSendCachedCredentialedTokenToAnotherSite checks the
// same for a token already cached from a legitimate pull: a challenge that
// names the same realm, service and scope must not be handed it.
func TestTransportDoesNotSendCachedCredentialedTokenToAnotherSite(t *testing.T) {
var issued atomic.Int32
realm := tokenService(t, &issued)
var legitimate, leaked []string
registry := challengingRegistry(t, realm.URL, &legitimate)
evil := challengingRegistry(t, realm.URL, &leaked)

// The registry itself has configured credentials, so it is trusted with
// tokens from its realm.
auth := func(rawURL string) (string, string) {
if strings.HasPrefix(rawURL, realm.URL) || strings.HasPrefix(rawURL, registry.URL) {
return "Authorization", "Basic Y29ycDpwYXNz"
}
return "", ""
}
client := &http.Client{Transport: NewTransport(http.DefaultTransport, auth)}
if status := get(t, client, registry.URL+"/v2/corp/private/blobs/sha256:abc"); status != http.StatusOK {
t.Fatalf("legitimate pull: status = %d, want 200", status)
}
if got := legitimate[len(legitimate)-1]; got != "Bearer secret-token" {
t.Fatalf("legitimate pull sent %q, want the credentialed token", got)
}

get(t, client, evil.URL+"/v2/corp/private/blobs/sha256:abc")
for _, got := range leaked {
if strings.HasPrefix(got, "Bearer ") {
t.Errorf("the challenging host received %q", got)
}
}
}

// TestTransportFollowsAnonymousTokenServiceOnAnotherHost keeps public pulls
// working where the token service is on another host and no credentials are
// configured for it: the token is anonymous, so any registry may have it.
func TestTransportFollowsAnonymousTokenServiceOnAnotherHost(t *testing.T) {
var issued atomic.Int32
realm := tokenService(t, &issued)
var received []string
registry := challengingRegistry(t, realm.URL, &received)

client := &http.Client{Transport: NewTransport(http.DefaultTransport, credentialsFor("https://elsewhere.example"))}
if status := get(t, client, registry.URL+"/v2/library/alpine/blobs/sha256:abc"); status != http.StatusOK {
t.Errorf("status = %d, want 200", status)
}
if got := received[len(received)-1]; got != "Bearer anonymous-token" {
t.Errorf("registry received %q, want the anonymous token", got)
}
}

func TestSameSite(t *testing.T) {
for _, tt := range []struct {
registry, realm string
want bool
}{
{"https://registry-1.docker.io", "https://auth.docker.io", true},
{"https://registry.gitlab.com", "https://gitlab.com", true},
{"https://ghcr.io", "https://ghcr.io", true},
{"https://registry.corp.example", "https://auth.corp.example:8443", true},
{"https://evil.example", "https://auth.docker.io", false},
{"https://docker.io.evil.example", "https://auth.docker.io", false},
{"https://a.github.io", "https://b.github.io", false}, // public suffix: separate sites
{"http://127.0.0.1:5000", "http://127.0.0.1:5000", true},
{"http://127.0.0.1:5000", "http://127.0.0.1:5001", false},
{"http://localhost:5000", "http://localhost:5001", false},
} {
if got := sameSite(mustParse(t, tt.registry), mustParse(t, tt.realm)); got != tt.want {
t.Errorf("sameSite(%s, %s) = %v, want %v", tt.registry, tt.realm, got, tt.want)
}
}
}

func mustParse(t *testing.T, rawURL string) *url.URL {
t.Helper()
u, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
return u
}
42 changes: 41 additions & 1 deletion internal/httpclient/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"
"sync"
"time"

"golang.org/x/net/publicsuffix"
)

const (
Expand Down Expand Up @@ -88,7 +90,7 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
}

challenge, ok := parseBearerChallenge(resp.Header.Values("WWW-Authenticate"))
if !ok || !canReplay(req) {
if !ok || !canReplay(req) || !t.trustedWithToken(req.URL, challenge) {
return resp, nil
}

Expand All @@ -108,6 +110,44 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.base.RoundTrip(retry)
}

// trustedWithToken reports whether a token from challenge's realm may be
// fetched for, and sent to, the registry at requestURL. The challenge comes
// from that registry, which can be any host the proxy fetches from, such as
// one named in a Composer dist URL or a Helm index. Credentials configured for
// the realm would otherwise be turned into a token handed to that host. A
// registry is trusted with such a token when it has configured credentials of
// its own, or is on the same site as the realm, as Docker Hub's
// registry-1.docker.io is with auth.docker.io. A token from a realm with no
// configured credentials is anonymous, and any registry may have it.
func (t *Transport) trustedWithToken(requestURL *url.URL, challenge bearerChallenge) bool {
if t.authForURL == nil {
return true
}
if name, value := t.authForURL(challenge.realm); name == "" || value == "" {
return true
}
if name, value := t.authForURL(requestURL.String()); name != "" && value != "" {
return true
}
realm, err := url.Parse(challenge.realm)
return err == nil && sameSite(requestURL, realm)
}

// sameSite reports whether two URLs are on the same site: the same registrable
// domain (public suffix plus one label), or, for IP addresses and names
// without one, such as localhost, the same host and port.
func sameSite(a, b *url.URL) bool {
hostA, hostB := strings.ToLower(a.Hostname()), strings.ToLower(b.Hostname())
if net.ParseIP(hostA) == nil && net.ParseIP(hostB) == nil {
siteA, errA := publicsuffix.EffectiveTLDPlusOne(hostA)
siteB, errB := publicsuffix.EffectiveTLDPlusOne(hostB)
if errA == nil && errB == nil {
return siteA == siteB
}
}
return strings.EqualFold(a.Host, b.Host)
}

func (t *Transport) applyAuthentication(req *http.Request, hasExplicitAuthorization bool) {
t.applyConfiguredAuthentication(req)
if hasExplicitAuthorization {
Expand Down
Loading