From 15019ad0f9aff96f1fe25e253a75c674b4892491 Mon Sep 17 00:00:00 2001 From: Massimiliano Ferrero Date: Sat, 20 Jun 2026 14:26:22 +0200 Subject: [PATCH] fix: reject manifest PUT whose digest reference does not match the content A reference containing ":" addresses a manifest by digest; the submitted content must hash to exactly that digest. Previously a mismatched or malformed digest reference was stored as a tag under the wrong key (the content checksum still matched), returning 201 instead of 400. Now returns 400 DIGEST_INVALID. Adds regression tests (mismatched digest, malformed digest, correct digest). --- src/errors.ts | 8 ++++++- src/registry/r2.ts | 11 ++++++++++ test/index.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 95e655f..0508415 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -72,7 +72,13 @@ export class InternalError extends Response { export class ManifestError extends Response { constructor( - code: "MANIFEST_INVALID" | "BLOB_UNKNOWN" | "MANIFEST_UNVERIFIED" | "TAG_INVALID" | "NAME_INVALID", + code: + | "MANIFEST_INVALID" + | "BLOB_UNKNOWN" + | "MANIFEST_UNVERIFIED" + | "TAG_INVALID" + | "NAME_INVALID" + | "DIGEST_INVALID", message: string, detail: Record = {}, ) { diff --git a/src/registry/r2.ts b/src/registry/r2.ts index 7f01098..5ea8f46 100644 --- a/src/registry/r2.ts +++ b/src/registry/r2.ts @@ -509,6 +509,17 @@ export class R2Registry implements Registry { shaWriter.close(); const digest = await sha256.digest; const digestStr = hexToDigest(digest); + + // A reference containing ":" addresses the manifest by digest (an OCI tag never contains ":"). + // The submitted content must hash to exactly that digest; a mismatched or malformed digest + // reference is a client error (400 DIGEST_INVALID), not a tag to store under the wrong key. + if (reference.includes(":") && reference !== digestStr) { + const message = isValidDigest(reference) + ? `provided digest ${reference} does not match content digest ${digestStr}` + : `invalid digest reference ${reference}`; + return { response: new ManifestError("DIGEST_INVALID", message) }; + } + const text = await blob.text(); let manifestJSON: unknown; try { diff --git a/test/index.test.ts b/test/index.test.ts index 65ebe53..51a7a0b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2380,3 +2380,55 @@ test("docker.io", () => { } } }); + +describe("manifest PUT digest validation", () => { + test("PUT manifest by a mismatched digest is rejected 400 DIGEST_INVALID (not stored)", async () => { + const name = "manifestdigest/mismatch"; + const manifest = await generateManifest(name); + const data = JSON.stringify(manifest); + const wrongDigest = "sha256:" + "0".repeat(64); + const res = await fetch( + createRequest("PUT", `/v2/${name}/manifests/${wrongDigest}`, new Blob([data]).stream(), { + "Content-Type": "application/vnd.oci.image.manifest.v1+json", + }), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { errors: { code: string }[] }; + expect(body.errors[0].code).toBe("DIGEST_INVALID"); + // Must NOT have been stored under the wrong digest key. + const get = await fetch(createRequest("GET", `/v2/${name}/manifests/${wrongDigest}`, null)); + expect(get.status).toBe(404); + }); + + test("PUT manifest by a malformed digest reference is rejected 400 DIGEST_INVALID", async () => { + const name = "manifestdigest/malformed"; + const manifest = await generateManifest(name); + const res = await fetch( + createRequest( + "PUT", + `/v2/${name}/manifests/sha256:baddigeststring`, + new Blob([JSON.stringify(manifest)]).stream(), + { + "Content-Type": "application/vnd.oci.image.manifest.v1+json", + }, + ), + ); + expect(res.status).toBe(400); + const body = (await res.json()) as { errors: { code: string }[] }; + expect(body.errors[0].code).toBe("DIGEST_INVALID"); + }); + + test("PUT manifest by its correct digest still succeeds (201)", async () => { + const name = "manifestdigest/correct"; + const manifest = await generateManifest(name); + const data = JSON.stringify(manifest); + const digest = await getSHA256(data); + const res = await fetch( + createRequest("PUT", `/v2/${name}/manifests/${digest}`, new Blob([data]).stream(), { + "Content-Type": "application/vnd.oci.image.manifest.v1+json", + }), + ); + expect(res.status).toBe(201); + expect(res.headers.get("docker-content-digest")).toEqual(digest); + }); +});