diff --git a/README.md b/README.md index b468ea0..59fe561 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,19 @@ Set the USERNAME and PASSWORD as secrets with `npx wrangler secret put USERNAME You can add a base64 encoded JWT public key to verify passwords (or token) that are signed by the private key. `npx wrangler secret put JWT_REGISTRY_TOKENS_PUBLIC_KEY --env production` +### Enabling anonymous (public) pull + +Set `READONLY_ANONYMOUS = "true"` to allow unauthenticated pulls. With the flag on, requests +that carry no `Authorization` header are treated as holding the `pull` capability, so +`GET`/`HEAD` (manifests, blobs, referrers, tag listings) succeed without credentials. Write +methods (`POST`/`PUT`/`PATCH`/`DELETE`) still require an authenticated push credential — an +anonymous write is rejected with the usual `WWW-Authenticate` challenge. A request that does +present an `Authorization` header always takes the normal credential path. + +The flag defaults off, preserving the existing behavior where every request requires +authentication. Enable it only when the whole namespace served by the worker is meant to be +world-readable. + ### Using with Docker You can use this registry with Docker to push and pull images. diff --git a/index.ts b/index.ts index 08dacdb..0cbe9ad 100644 --- a/index.ts +++ b/index.ts @@ -6,6 +6,8 @@ import { Router } from "itty-router"; import { AuthErrorResponse, InternalError } from "./src/errors"; import v2Router from "./src/router"; import { authenticationMethodFromEnv } from "./src/authentication-method"; +import { RegistryTokens } from "./src/token"; +import type { RegistryAuthProtocolTokenPayload } from "./src/auth"; import { Registry } from "./src/registry/registry"; import { R2Registry } from "./src/registry/r2"; @@ -21,6 +23,7 @@ export interface Env { PASSWORD?: string; READONLY_USERNAME?: string; READONLY_PASSWORD?: string; + READONLY_ANONYMOUS?: string; // "true" allows unauthenticated pulls (GET/HEAD); writes still require auth PUSH_COMPATIBILITY_MODE?: PushCompatibilityMode; REGISTRIES_JSON?: string; // should be in the format of RegistryConfiguration[]; REGISTRY_CLIENT: Registry; @@ -46,7 +49,10 @@ export default { return new AuthErrorResponse(request); } - const credentials = await authMethod.checkCredentials(request); + const credentials = + env.READONLY_ANONYMOUS === "true" && !request.headers.get("Authorization") + ? RegistryTokens.verifyPayload(request, anonymousPullPayload()) + : await authMethod.checkCredentials(request); if (!credentials.verified) { console.warn(`Not Authorized. authmode=${authMethod.authmode}. verified=false`); return new AuthErrorResponse(request); @@ -81,6 +87,19 @@ export default { }, } satisfies ExportedHandler; +// anonymousPullPayload returns a principal holding only the "pull" capability. It authorizes +// unauthenticated read requests (GET/HEAD) when READONLY_ANONYMOUS is enabled; writes still fail +// because verifyPayload requires the "push" capability for POST/PUT/PATCH/DELETE. +function anonymousPullPayload(): RegistryAuthProtocolTokenPayload { + return { + username: "anonymous", + capabilities: ["pull"], + // verifyPayload compares exp against Date.now() expressed in seconds, so exp is seconds. + exp: Math.floor(Date.now() / 1000) + 3600, + aud: "", + }; +} + const ensureConfig = (env: Env): boolean => { if (!env.REGISTRY) { console.error( diff --git a/test/index.test.ts b/test/index.test.ts index 65ebe53..a0137fc 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2380,3 +2380,88 @@ test("docker.io", () => { } } }); + +// Fetch with no credentials and READONLY_ANONYMOUS enabled. +async function fetchAnonymousReadonly(r: Request): Promise { + const ctx = createExecutionContext(); + const res = await worker.fetch(r, { ...env, READONLY_ANONYMOUS: "true" } as Env, ctx); + await waitOnExecutionContext(ctx); + return res as Response; +} + +describe("anonymous pull (READONLY_ANONYMOUS)", () => { + test("flag OFF (default): anonymous reads are rejected", async () => { + const base = await fetchUnauth(createRequest("GET", "/v2/", null)); + expect(base.status).toBe(401); + + const manifest = await fetchUnauth(createRequest("GET", "/v2/somename/manifests/latest", null)); + expect(manifest.status).toBe(401); + }); + + test("flag ON: anonymous GET /v2/ succeeds", async () => { + const res = await fetchAnonymousReadonly(createRequest("GET", "/v2/", null)); + expect(res.status).toBe(200); + }); + + test("flag ON: anonymous read of a pushed manifest, blob and tag list succeeds", async () => { + const name = "anonymous/pullable"; + const manifest = await generateManifest(name); + const { sha256 } = await createManifest(name, manifest, "latest"); + + // Manifest by tag, by digest, and HEAD — all anonymous. + const byTag = await fetchAnonymousReadonly(createRequest("GET", `/v2/${name}/manifests/latest`, null)); + expect(byTag.status).toBe(200); + const byDigest = await fetchAnonymousReadonly(createRequest("GET", `/v2/${name}/manifests/${sha256}`, null)); + expect(byDigest.status).toBe(200); + const head = await fetchAnonymousReadonly(createRequest("HEAD", `/v2/${name}/manifests/latest`, null)); + expect(head.status).toBe(200); + + // Each referenced blob is anonymously readable. + for (const layerDigest of getLayersFromManifest(manifest)) { + const blob = await fetchAnonymousReadonly(createRequest("GET", `/v2/${name}/blobs/${layerDigest}`, null)); + expect(blob.status).toBe(200); + } + + // tags/list is anonymously readable. + const tags = await fetchAnonymousReadonly(createRequest("GET", `/v2/${name}/tags/list`, null)); + expect(tags.status).toBe(200); + const tagsBody = (await tags.json()) as { name: string; tags: string[] }; + expect(tagsBody.tags).toContain("latest"); + }); + + test("flag ON: anonymous writes are still rejected with the auth challenge", async () => { + const name = "anonymous/readonly"; + + const post = await fetchAnonymousReadonly(createRequest("POST", `/v2/${name}/blobs/uploads/`, null)); + expect(post.status).toBe(401); + expect(post.headers.get("WWW-Authenticate")).not.toBeNull(); + + const put = await fetchAnonymousReadonly( + createRequest("PUT", `/v2/${name}/manifests/latest`, new Blob(["{}"]).stream(), { + "Content-Type": "application/gzip", + }), + ); + expect(put.status).toBe(401); + expect(put.headers.get("WWW-Authenticate")).not.toBeNull(); + + const del = await fetchAnonymousReadonly(createRequest("DELETE", `/v2/${name}/manifests/latest`, null)); + expect(del.status).toBe(401); + }); + + test("flag ON: an authenticated push still succeeds", async () => { + const name = "anonymous/stillpushable"; + const manifest = await generateManifest(name); + const { sha256 } = await createManifest(name, manifest, "v1"); + expect(sha256).toBeTruthy(); + }); + + test("flag ON: a presented credential is still honored (not bypassed)", async () => { + // A request carrying an Authorization header takes the normal credential path even with the + // flag on; a wrong password is rejected rather than silently treated as anonymous. + const ctx = createExecutionContext(); + const r = createRequest("GET", "/v2/", null, { Authorization: usernamePasswordToAuth("hello", "wrong") }); + const res = (await worker.fetch(r, { ...env, READONLY_ANONYMOUS: "true" } as Env, ctx)) as Response; + await waitOnExecutionContext(ctx); + expect(res.status).toBe(401); + }); +}); diff --git a/wrangler.example.jsonc b/wrangler.example.jsonc index 868f888..0602df4 100644 --- a/wrangler.example.jsonc +++ b/wrangler.example.jsonc @@ -32,6 +32,8 @@ "PASSWORD": "world", // "READONLY_USERNAME": "readonly", // "READONLY_PASSWORD": "readonly" + // Set READONLY_ANONYMOUS to "true" to allow unauthenticated pulls (writes still require auth) + // "READONLY_ANONYMOUS": "true" // The necessary secrets are: // Setup those secrets on .dev.vars in the root of the project }, diff --git a/wrangler.example.toml b/wrangler.example.toml index 15090b4..d9e4136 100644 --- a/wrangler.example.toml +++ b/wrangler.example.toml @@ -32,6 +32,8 @@ USERNAME = "hello" PASSWORD = "world" # READONLY_USERNAME = "readonly" # READONLY_PASSWORD = "readonly" +# Set READONLY_ANONYMOUS to "true" to allow unauthenticated pulls (writes still require auth) +# READONLY_ANONYMOUS = "true" # The necessary secrets are: # Setup those secrets on .dev.vars in the root of the project #