cloudflare / cloudflare/serverless-registry
Pull-through fallback fails with ghcr.io: token scope is double-prefixed and includes push permission
- Dominant language
- TypeScript
- Stars
- 1.5k
- Forks
- 122
- PR merge metrics
- No merged PRs in 30d
Description
## Description
When configuring ghcr.io as a fallback (upstream) registry via `REGISTRIES_JSON`, pull-through fallback always fails with `manifest unknown` (404). The same configuration works fine for Docker Hub, GCR, quay.io and registry.k8s.io.
## Root cause
Two issues in `src/registry/http.ts` break token-based authentication for registries like ghcr.io:
### 1. Scope returned by upstream `/v2/` is used as-is and double-prefixed
`authenticate()` only overwrites the scope when it's empty:
```ts
const authCtx = authHeaderIntoAuthContext(this.url, authenticateHeader);
if (!authCtx.scope) authCtx.scope = namespace;
```
This assumes the upstream `/v2/` endpoint returns a `WWW-Authenticate` header **without** a `scope` attribute (this is what Docker Hub does). However, ghcr.io returns a **placeholder scope**:
```
WWW-Authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"
```
So `authCtx.scope` becomes `repository:user/image:pull`, and `authenticateBearer()` then builds:
```
scope: `repository:${ctx.scope}:pull,push`
=> "repository:repository:user/image:pull:pull,push"
```
which the token endpoint rejects.
### 2. Requesting `:pull,push` is rejected by ghcr.io
Even with the scope prefix fixed, ghcr.io's token endpoint returns `403 DENIED` for combined `:pull,push` scope:
```
GET https://ghcr.io/token?service=ghcr.io&scope=repository:actions/actions-runner:pull,push
=> {"errors":[{"code":"DENIED","message":"requested access to the resource is denied"}]}
GET https://ghcr.io/token?service=ghcr.io&scope=repository:actions/actions-runner:pull
=> {"token":"..."} (200 OK)
```
Since fallback is a read-only operation, requesting push permission is unnecessary anyway.
## Proposed fix
Two one-line changes in `src/registry/http.ts`:
1. Always use the request namespace for the scope (ignore the upstream placeholder):
```ts
authCtx.scope = namespace;
```
2. Request only `:pull` (fallback never pushes):
```ts
scope: `repository:${ctx.scope}:pull`,
```
## Verification
After the fix, with `REGISTRIES_JSON` containing Docker Hub + ghcr.io + registry.k8s.io + quay.io + gcr.io:
| Registry | Image | Before | After |
|---|---|---|---|
| ghcr.io | actions/actions-runner | 404 | 200 |
| ghcr.io | home-assistant/home-assistant | 404 | 200 |
| ghcr.io | open-telemetry/opentelemetry-collector-releases/opentelemetry-collector | 404 | 200 |
| docker.io | library/alpine | 200 | 200 |
| registry.k8s.io | pause | 200 | 200 |
| quay.io | prometheus/node-exporter | 200 | 200 |
| gcr.io | google-containers/pause | 200 | 200 |
## Environment
- serverless-registry @ main (20cd590)
- wrangler 4.118.0
Contributor guide
Research direction
Start in src/registry/http.ts, focusing on authenticate() and authenticateBearer() and the scope values they construct. Apply the proposed authentication changes, then verify the REGISTRIES_JSON fallback matrix: ghcr.io pulls return 200 while Docker Hub, registry.k8s.io, quay.io, and gcr.io continue to work.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- docker, typescript
- Domain
- authentication, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100