Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 08:09:17 -05:00
pkg-proxy/internal/httpclient/token_scope_test.go
Simon Chrzanowski 6a90fb411f Only send credentialed registry tokens to trusted registries
Credentials configured for a token service were applied to any challenge
naming it. A host the proxy fetches from, such as one in a Composer dist
URL or a Helm index, could answer a /v2/ request with a Bearer challenge
naming that realm, and the proxy fetched a token with the configured
credentials and sent it to that host. A token cached from a legitimate
pull was handed over the same way.

- Follow a challenge to a realm with configured credentials only for a
  registry that has configured credentials of its own, or is on the same
  site (registrable domain) as the realm, as registry-1.docker.io is with
  auth.docker.io; otherwise pass the 401 back
- Keep following anonymous challenges for any registry, as before
- Document the rule
2026-10-14 17:56:18 +02:00

173 lines
6.2 KiB
Go

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
}