Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 20 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -81,6 +87,19 @@ export default {
},
} satisfies ExportedHandler<Env>;

// 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(
Expand Down
85 changes: 85 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2380,3 +2380,88 @@ test("docker.io", () => {
}
}
});

// Fetch with no credentials and READONLY_ANONYMOUS enabled.
async function fetchAnonymousReadonly(r: Request): Promise<Response> {
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);
});
});
2 changes: 2 additions & 0 deletions wrangler.example.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
2 changes: 2 additions & 0 deletions wrangler.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
#