diff --git a/index.ts b/index.ts index 08dacdb..55a9ebf 100644 --- a/index.ts +++ b/index.ts @@ -21,6 +21,10 @@ export interface Env { PASSWORD?: string; READONLY_USERNAME?: string; READONLY_PASSWORD?: string; + // Set to "true" to serve pull requests (GET/HEAD) without authentication. + // Mutating methods (push, delete) still require credentials. Note this also + // exposes read-only discovery endpoints like /v2/_catalog anonymously. + ANONYMOUS_PULL?: string; PUSH_COMPATIBILITY_MODE?: PushCompatibilityMode; REGISTRIES_JSON?: string; // should be in the format of RegistryConfiguration[]; REGISTRY_CLIENT: Registry; @@ -41,15 +45,18 @@ export default { return new AuthErrorResponse(request); } - const authMethod = await authenticationMethodFromEnv(env); - if (!authMethod) { - return new AuthErrorResponse(request); - } + const anonymousPull = env.ANONYMOUS_PULL === "true" && (request.method === "GET" || request.method === "HEAD"); + if (!anonymousPull) { + const authMethod = await authenticationMethodFromEnv(env); + if (!authMethod) { + return new AuthErrorResponse(request); + } - const credentials = await authMethod.checkCredentials(request); - if (!credentials.verified) { - console.warn(`Not Authorized. authmode=${authMethod.authmode}. verified=false`); - return new AuthErrorResponse(request); + const credentials = await authMethod.checkCredentials(request); + if (!credentials.verified) { + console.warn(`Not Authorized. authmode=${authMethod.authmode}. verified=false`); + return new AuthErrorResponse(request); + } } env.REGISTRY_CLIENT = new R2Registry(env); diff --git a/test/index.test.ts b/test/index.test.ts index 65ebe53..1d26df7 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2380,3 +2380,63 @@ test("docker.io", () => { } } }); + +describe("anonymous pull", () => { + async function fetchAnonymousPull(r: Request): Promise { + const ctx = createExecutionContext(); + const res = await worker.fetch(r, { ...env, ANONYMOUS_PULL: "true" } as Env, ctx); + await waitOnExecutionContext(ctx); + return res as Response; + } + + test("GET /v2/ needs no credentials when ANONYMOUS_PULL is enabled", async () => { + const res = await fetchAnonymousPull(createRequest("GET", "/v2/", null)); + expect(res.status).toBe(200); + }); + + test("GET /v2/ still requires credentials when ANONYMOUS_PULL is disabled", async () => { + const res = await fetchUnauth(createRequest("GET", "/v2/", null)); + expect(res.status).toBe(401); + }); + + test("manifests and blobs pull anonymously, with bad credentials ignored", async () => { + const name = "anonymous-pull-test"; + const manifest = await generateManifest(name); + const { sha256 } = await uploadManifest(name, manifest, "latest"); + + const getRes = await fetchAnonymousPull(createRequest("GET", `/v2/${name}/manifests/latest`, null)); + expect(getRes.status).toBe(200); + expect(getRes.headers.get("docker-content-digest")).toEqual(sha256); + + const headRes = await fetchAnonymousPull(createRequest("HEAD", `/v2/${name}/manifests/latest`, null)); + expect(headRes.status).toBe(200); + + const imageManifest = getImageManifestV2(manifest); + const blobRes = await fetchAnonymousPull( + createRequest("GET", `/v2/${name}/blobs/${imageManifest.layers[0].digest}`, null), + ); + expect(blobRes.status).toBe(200); + + // Garbage credentials on a pull must not break anonymous access. + const badCredRes = await fetchAnonymousPull( + createRequest("GET", `/v2/${name}/manifests/latest`, null, { + Authorization: usernamePasswordToAuth("nobody", "wrong"), + }), + ); + expect(badCredRes.status).toBe(200); + }); + + test("mutating methods still require credentials when ANONYMOUS_PULL is enabled", async () => { + const name = "anonymous-pull-test-mutations"; + const uploadRes = await fetchAnonymousPull(createRequest("POST", `/v2/${name}/blobs/uploads/`, null)); + expect(uploadRes.status).toBe(401); + + const putRes = await fetchAnonymousPull( + createRequest("PUT", `/v2/${name}/manifests/latest`, new Blob(["{}"]).stream()), + ); + expect(putRes.status).toBe(401); + + const deleteRes = await fetchAnonymousPull(createRequest("DELETE", `/v2/${name}/manifests/latest`, null)); + expect(deleteRes.status).toBe(401); + }); +});