Files
Felis/internal/registrygate/gate_test.go
T

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)
}
}
}