mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
Both are pre-existing and were found while working on finding 27.
sendAuthError built its "docker login <host>" line from r.Host. /auth/token is
served on the UI domain as well as on every registry domain, and the
WWW-Authenticate realm points at the UI domain's copy, so a client following
the realm was told to run "docker login seamark.dev" - the one host that
deliberately refuses /v2/* with an OCI UNSUPPORTED error pointing at
seamark.cr. It now uses the service resolved for the token, which is the
registry domain, falling back to the deployment's primary rather than to
r.Host. The single-domain case still prints a host that serves /v2/, and an
unconfigured service supplied by the client cannot steer it.
Separately, the scope parameter was read with .Get, taking the first value
only. The Docker token spec allows scope to be repeated, so a client asking for
two repositories was issued a token covering one and got a 401 on the other.
Both wire forms are now flattened, on the GET query string and on the OAuth2
POST body, which had the same defect via PostFormValue.
Empty and whitespace-only values are dropped. Exact duplicate scope strings
collapse, but two entries naming the same repository with different actions are
left alone: merging them would union the action sets, and every gate downstream
is written only to narrow.
More entries now reach the anonymous gate added in af7522b, which is the
intended effect. Its per-entry verdict is unchanged: public entries survive,
private ones are dropped, and an all-private request still gets the challenge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
146 lines
4.8 KiB
Go
146 lines
4.8 KiB
Go
package token
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The plain-text guidance sent with a 401 has to name a host `docker login`
|
|
// can actually handshake against. /auth/token answers on the UI domain (that
|
|
// is where the WWW-Authenticate realm points) as well as on every registry
|
|
// domain, and the UI domain refuses /v2/* outright, so the guidance must name
|
|
// the resolved registry service rather than echoing the request's own host.
|
|
func TestSendAuthError_NamesRegistryDomainNotRequestHost(t *testing.T) {
|
|
keyPath := getSharedTestKey(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
// services declared on the handler; the issuer's service is the first.
|
|
services []string
|
|
// host the request arrives on.
|
|
host string
|
|
// service parameter the client echoes back, if any.
|
|
requested string
|
|
|
|
wantLogin string // hostname step 2 must name
|
|
wantAbsent []string // hostnames step 2 must not name
|
|
}{
|
|
{
|
|
// seamark-shaped: UI on seamark.dev, registry on seamark.cr. A
|
|
// client following the realm lands on the UI host.
|
|
name: "split domain, request on the UI host",
|
|
services: []string{"seamark.cr"},
|
|
host: "seamark.dev",
|
|
wantLogin: "seamark.cr",
|
|
wantAbsent: []string{"docker login seamark.dev"},
|
|
},
|
|
{
|
|
name: "split domain, request on the registry host",
|
|
services: []string{"seamark.cr"},
|
|
host: "seamark.cr",
|
|
wantLogin: "seamark.cr",
|
|
},
|
|
{
|
|
// Multiple front doors: the guidance follows the service the
|
|
// client is authenticating against, not the primary.
|
|
name: "secondary registry domain via ?service=",
|
|
services: []string{"seamark.cr", "buoy.cr"},
|
|
host: "seamark.dev",
|
|
requested: "buoy.cr",
|
|
wantLogin: "buoy.cr",
|
|
wantAbsent: []string{"docker login seamark.dev"},
|
|
},
|
|
{
|
|
// ?service= is client-influenced, so an unconfigured value falls
|
|
// back to the primary registry domain, never to r.Host.
|
|
name: "unconfigured ?service= falls back to the primary",
|
|
services: []string{"seamark.cr"},
|
|
host: "seamark.dev",
|
|
requested: "evil.example",
|
|
wantLogin: "seamark.cr",
|
|
wantAbsent: []string{"docker login seamark.dev", "evil.example"},
|
|
},
|
|
{
|
|
// Single-domain deployment (the dev stack): the UI host is the
|
|
// registry domain, and the printed hostname is still correct.
|
|
name: "single domain, UI host is the registry domain",
|
|
services: []string{"localhost"},
|
|
host: "localhost:5000",
|
|
wantLogin: "localhost",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
issuer, err := NewIssuer(keyPath, "atcr.io", tt.services[0], 15*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("NewIssuer() error = %v", err)
|
|
}
|
|
handler := NewHandler(issuer, nil)
|
|
handler.SetServices(tt.services)
|
|
|
|
// A push-only scope with no credentials has no anonymous
|
|
// component, so it draws the plain-text challenge.
|
|
target := "/auth/token?scope=repository:bob.bsky.social/myapp:push"
|
|
if tt.requested != "" {
|
|
target += "&service=" + tt.requested
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
|
req.Host = tt.host
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d. Body: %s", w.Code, http.StatusUnauthorized, w.Body.String())
|
|
}
|
|
|
|
body := w.Body.String()
|
|
want := "docker login " + tt.wantLogin
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("guidance does not contain %q.\nBody:\n%s", want, body)
|
|
}
|
|
for _, absent := range tt.wantAbsent {
|
|
if strings.Contains(body, absent) {
|
|
t.Errorf("guidance contains %q, which cannot serve /v2/.\nBody:\n%s", absent, body)
|
|
}
|
|
}
|
|
|
|
// Step 1 stays on the request's own base URL: the install page
|
|
// lives on the UI domain and registry domains redirect there.
|
|
if wantInstall := getBaseURL(req) + "/install"; !strings.Contains(body, wantInstall) {
|
|
t.Errorf("guidance does not contain install URL %q.\nBody:\n%s", wantInstall, body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A handler whose issuer has no service configured has no registry domain to
|
|
// name, so it drops the docker login step rather than printing a host that
|
|
// cannot work.
|
|
func TestSendAuthError_NoServiceOmitsDockerLoginStep(t *testing.T) {
|
|
keyPath := getSharedTestKey(t)
|
|
|
|
issuer, err := NewIssuer(keyPath, "atcr.io", "", 15*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("NewIssuer() error = %v", err)
|
|
}
|
|
handler := NewHandler(issuer, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/auth/token?scope=repository:bob.bsky.social/myapp:push", nil)
|
|
req.Host = "seamark.dev"
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
|
}
|
|
if body := w.Body.String(); strings.Contains(body, "docker login") {
|
|
t.Errorf("guidance names a docker login host with no service configured.\nBody:\n%s", body)
|
|
}
|
|
}
|