259 lines
9.0 KiB
Go
259 lines
9.0 KiB
Go
package registrygate
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
type upstreamLog struct {
|
|
mu sync.Mutex
|
|
seen []string
|
|
auth []string
|
|
}
|
|
|
|
func (u *upstreamLog) handler() http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u.mu.Lock()
|
|
u.seen = append(u.seen, r.Method+" "+r.URL.RequestURI())
|
|
u.auth = append(u.auth, r.Header.Get("Authorization"))
|
|
u.mu.Unlock()
|
|
switch r.Method {
|
|
case http.MethodPost:
|
|
w.Header().Set("Location", "/v2/x/blobs/uploads/abc")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
case http.MethodPut:
|
|
w.WriteHeader(http.StatusCreated)
|
|
default:
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (u *upstreamLog) count() int {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
return len(u.seen)
|
|
}
|
|
|
|
func newGate(t *testing.T) (*httptest.Server, *upstreamLog) {
|
|
t.Helper()
|
|
up := &upstreamLog{}
|
|
upSrv := httptest.NewServer(up.handler())
|
|
t.Cleanup(upSrv.Close)
|
|
target, _ := url.Parse(upSrv.URL)
|
|
g := New(target, map[string]string{PrincipalPlatform: "plat-secret", PrincipalBuild: "build-secret"}, nil)
|
|
gs := httptest.NewServer(g)
|
|
t.Cleanup(gs.Close)
|
|
return gs, up
|
|
}
|
|
|
|
func do(t *testing.T, srv *httptest.Server, method, path, user, pass string) *http.Response {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, srv.URL+path, strings.NewReader(""))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if user != "" {
|
|
req.SetBasicAuth(user, pass)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
return resp
|
|
}
|
|
|
|
func TestAnonymousReadsPassButTheAPIRootChallenges(t *testing.T) {
|
|
gs, up := newGate(t)
|
|
for _, p := range []string{
|
|
"/v2/felis/felis/manifests/v0.1.0",
|
|
"/v2/felis/felis/blobs/sha256:abc",
|
|
"/v2/mirror/trivy-db/manifests/2",
|
|
"/v2/_catalog",
|
|
"/v2/user-uploads/s1/tags/list",
|
|
} {
|
|
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
|
if resp := do(t, gs, m, p, "", ""); resp.StatusCode != http.StatusOK {
|
|
t.Errorf("%s %s anonymous = %d, want 200", m, p, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
// Docker's daemon only sends credentials on a push if the API root challenged
|
|
// it, so the root must say 401 + Basic to anonymous callers.
|
|
resp := do(t, gs, http.MethodGet, "/v2/", "", "")
|
|
if resp.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(resp.Header.Get("WWW-Authenticate"), "Basic ") {
|
|
t.Fatalf("anonymous GET /v2/ = %d %q, want 401 Basic challenge", resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
|
|
}
|
|
if resp := do(t, gs, http.MethodGet, "/v2/", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("authenticated GET /v2/ = %d, want 200", resp.StatusCode)
|
|
}
|
|
if resp := do(t, gs, http.MethodGet, "/v2/", PrincipalBuild, "wrong"); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("GET /v2/ with a bad secret = %d, want 401", resp.StatusCode)
|
|
}
|
|
_ = up
|
|
}
|
|
|
|
func TestAnonymousWritesNeverReachTheRegistry(t *testing.T) {
|
|
gs, up := newGate(t)
|
|
for _, c := range []struct{ method, path string }{
|
|
{http.MethodPost, "/v2/felis/felis/blobs/uploads/"},
|
|
{http.MethodPut, "/v2/felis/felis/manifests/v0.1.0"},
|
|
{http.MethodPatch, "/v2/user-uploads/s1/blobs/uploads/abc"},
|
|
{http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc"},
|
|
} {
|
|
resp := do(t, gs, c.method, c.path, "", "")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("anonymous %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
|
|
}
|
|
if resp := do(t, gs, c.method, c.path, PrincipalPlatform, "not-it"); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("bad-secret %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
|
|
}
|
|
if resp := do(t, gs, c.method, c.path, "intruder", "plat-secret"); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("unknown-user %s %s = %d, want 401", c.method, c.path, resp.StatusCode)
|
|
}
|
|
}
|
|
if n := up.count(); n != 0 {
|
|
t.Fatalf("%d refused writes reached the registry: %v", n, up.seen)
|
|
}
|
|
}
|
|
|
|
func TestBuildPrincipalIsFencedOffPlatformRepos(t *testing.T) {
|
|
gs, up := newGate(t)
|
|
for _, p := range []string{
|
|
"/v2/felis/felis/manifests/v0.1.0",
|
|
"/v2/felis/limbo/blobs/uploads/",
|
|
"/v2/felis/manifests/latest",
|
|
"/v2/mirror/trivy-db/manifests/2",
|
|
"/v2/mirror/trivy-java-db/blobs/uploads/abc",
|
|
} {
|
|
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodPatch} {
|
|
if resp := do(t, gs, m, p, PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("build %s %s = %d, want 403", m, p, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
if resp := do(t, gs, http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc", PrincipalBuild, "build-secret"); resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("build DELETE = %d, want 403", resp.StatusCode)
|
|
}
|
|
if n := up.count(); n != 0 {
|
|
t.Fatalf("%d refused writes reached the registry: %v", n, up.seen)
|
|
}
|
|
|
|
for _, c := range []struct {
|
|
method, path string
|
|
want int
|
|
}{
|
|
{http.MethodPost, "/v2/user-uploads/s1/blobs/uploads/", http.StatusAccepted},
|
|
{http.MethodPatch, "/v2/user-uploads/s1/blobs/uploads/abc", http.StatusOK},
|
|
{http.MethodPut, "/v2/user-uploads/s1/blobs/uploads/abc?digest=sha256:0", http.StatusCreated},
|
|
{http.MethodPut, "/v2/user-uploads/s1/manifests/latest", http.StatusCreated},
|
|
{http.MethodPut, "/v2/modpacks/pack/manifests/1.0", http.StatusCreated},
|
|
// A repository merely containing "felis" deeper down is not reserved.
|
|
{http.MethodPut, "/v2/builds/felis/manifests/1", http.StatusCreated},
|
|
} {
|
|
if resp := do(t, gs, c.method, c.path, PrincipalBuild, "build-secret"); resp.StatusCode != c.want {
|
|
t.Errorf("build %s %s = %d, want %d", c.method, c.path, resp.StatusCode, c.want)
|
|
}
|
|
}
|
|
for i, a := range up.auth {
|
|
if a != "" {
|
|
t.Errorf("request %d (%s) reached the registry with an Authorization header", i, up.seen[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlatformPrincipalMayWriteAndDeleteAnything(t *testing.T) {
|
|
gs, _ := newGate(t)
|
|
for _, c := range []struct {
|
|
method, path string
|
|
want int
|
|
}{
|
|
{http.MethodPut, "/v2/felis/felis/manifests/v0.2.0", http.StatusCreated},
|
|
{http.MethodPost, "/v2/mirror/trivy-db/blobs/uploads/", http.StatusAccepted},
|
|
{http.MethodDelete, "/v2/user-uploads/s1/manifests/sha256:abc", http.StatusOK},
|
|
} {
|
|
if resp := do(t, gs, c.method, c.path, PrincipalPlatform, "plat-secret"); resp.StatusCode != c.want {
|
|
t.Errorf("platform %s %s = %d, want %d", c.method, c.path, resp.StatusCode, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPathTricksAreRefusedBeforeAuthorization(t *testing.T) {
|
|
gs, up := newGate(t)
|
|
for _, p := range []string{
|
|
"/v2/user-uploads/../felis/felis/manifests/v1",
|
|
"/v2/user-uploads/./x/manifests/v1",
|
|
"/v2/user-uploads%2F..%2Ffelis/felis/manifests/v1",
|
|
"/v2/user-uploads//felis/manifests/v1",
|
|
} {
|
|
req, _ := http.NewRequest(http.MethodPut, gs.URL, nil)
|
|
req.URL.Opaque = p // send the path exactly as written, no client-side cleaning
|
|
req.SetBasicAuth(PrincipalBuild, "build-secret")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("PUT %s = %d, want 400", p, resp.StatusCode)
|
|
}
|
|
}
|
|
if n := up.count(); n != 0 {
|
|
t.Fatalf("%d crafted paths reached the registry: %v", n, up.seen)
|
|
}
|
|
}
|
|
|
|
func TestMissingTokenFailsClosed(t *testing.T) {
|
|
up := &upstreamLog{}
|
|
upSrv := httptest.NewServer(up.handler())
|
|
defer upSrv.Close()
|
|
target, _ := url.Parse(upSrv.URL)
|
|
gs := httptest.NewServer(New(target, map[string]string{PrincipalBuild: ""}, nil))
|
|
defer gs.Close()
|
|
if resp := do(t, gs, http.MethodPut, "/v2/user-uploads/s1/manifests/latest", PrincipalBuild, ""); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("empty configured token accepted an empty password: %d", resp.StatusCode)
|
|
}
|
|
if resp := do(t, gs, http.MethodGet, "/v2/user-uploads/s1/manifests/latest", "", ""); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("reads must keep working without tokens: %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
gs, _ := newGate(t)
|
|
if resp := do(t, gs, http.MethodGet, "/healthz", "", ""); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("healthz = %d", resp.StatusCode)
|
|
}
|
|
dead, _ := url.Parse("http://127.0.0.1:1")
|
|
ds := httptest.NewServer(New(dead, nil, nil))
|
|
defer ds.Close()
|
|
if resp := do(t, ds, http.MethodGet, "/healthz", "", ""); resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Fatalf("healthz with a dead upstream = %d, want 503", resp.StatusCode)
|
|
}
|
|
if resp := do(t, ds, http.MethodGet, "/livez", "", ""); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("livez = %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestRepoFromPath(t *testing.T) {
|
|
for path, want := range map[string]string{
|
|
"/v2/": "",
|
|
"/v2/_catalog": "",
|
|
"/v2/a/manifests/latest": "a",
|
|
"/v2/a/b/c/manifests/sha256:0": "a/b/c",
|
|
"/v2/a/blobs/sha256:0": "a",
|
|
"/v2/a/b/blobs/uploads/": "a/b",
|
|
"/v2/a/b/blobs/uploads/uuid-1": "a/b",
|
|
"/v2/a/tags/list": "a",
|
|
"/v2/user-uploads/blobs/manifests/one": "user-uploads/blobs",
|
|
} {
|
|
if got := RepoFromPath(path); got != want {
|
|
t.Errorf("RepoFromPath(%q) = %q, want %q", path, got, want)
|
|
}
|
|
}
|
|
}
|