From 2f259026627045fdaab86bc2eb6ddbd9ce4a75b5 Mon Sep 17 00:00:00 2001 From: Andrew Gaul Date: Thu, 23 Jul 2026 16:54:15 -0700 Subject: [PATCH 1/2] Validate copy sources with HEAD instead of downloading the blob startCopyFromURL and copyFromURL validate the copy source by fetching ?comp=metadata through axios. Azurite serves that request as a full Blob_Download (issue #646), so every validated copy downloaded the entire source only to discard it, and a source blob declaring Content-Encoding: gzip over bytes that are not really gzip made axios decompression fail and turned every copy of that blob into a 500. Validate with a bodiless HEAD (Get Blob Properties) request instead. Error details live only in response bodies, and reading an archived source must fail the copy like the download did, so those cases repeat the request as the previous comp=metadata GET, now with axios decompression disabled. Co-Authored-By: Claude Fable 5 --- ChangeLog.md | 4 +++ src/blob/handlers/BlobHandler.ts | 39 +++++++++++++++++------- tests/blob/sas.test.ts | 52 ++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index d06a5d2d5..fb32ab1f5 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -4,6 +4,10 @@ ## Upcoming Release +Blob: + +- Copy source validation now issues a HEAD request instead of downloading the entire source blob, and no longer fails the copy with 500 when the source blob declares `Content-Encoding: gzip` (related to issue #646). + ## 2026.06 Version 3.36.0 General: diff --git a/src/blob/handlers/BlobHandler.ts b/src/blob/handlers/BlobHandler.ts index 0ab7be045..d0ef5f130 100644 --- a/src/blob/handlers/BlobHandler.ts +++ b/src/blob/handlers/BlobHandler.ts @@ -723,18 +723,35 @@ export default class BlobHandler extends BaseHandler implements IBlobHandler { context.contextId ); - // In order to retrieve proper error details we make a metadata request to the copy source. If we instead issue - // a HEAD request then the error details are not returned and reporting authentication failures to the caller - // becomes a black box. - const metadataUrl = URLBuilder.parse(copySource); - metadataUrl.setQueryParameter("comp", "metadata"); - const validationResponse: AxiosResponse = await axios.get( - metadataUrl.toString(), - { + // Azurite serves a GET of ?comp=metadata as a full blob + // download (issue #646). Besides downloading the whole source only to + // discard it, axios would fail the copy outright trying to decompress a + // source that declares Content-Encoding: gzip over bytes that are not + // really gzip. Validate access with a bodiless HEAD (Get Blob + // Properties) request instead, and only on failure repeat it as a GET + // to recover the error details from the response body, so reporting + // authentication failures does not become a black box. + let validationResponse: AxiosResponse = await axios.head(copySource, { + // Instructs axios to not throw an error for non-2xx responses + validateStatus: () => true + }); + if ( + validationResponse.status !== 200 || + validationResponse.headers["x-ms-access-tier"] === "Archive" + ) { + // Error details live only in response bodies, and reading an + // archived source must fail the copy the same way downloading it + // would, so repeat the request as a GET. + const metadataUrl = URLBuilder.parse(copySource); + metadataUrl.setQueryParameter("comp", "metadata"); + validationResponse = await axios.get(metadataUrl.toString(), { // Instructs axios to not throw an error for non-2xx responses - validateStatus: () => true - } - ); + validateStatus: () => true, + // The error body is uncompressed XML; never decompress, in case a + // race turns this retry into a 200 blob download after all. + decompress: false + }); + } if (validationResponse.status === 200) { this.logger.debug( `BlobHandler:startCopyFromURL() Successfully validated access to source account ${sourceAccount}`, diff --git a/tests/blob/sas.test.ts b/tests/blob/sas.test.ts index 3385324d3..f7e69b97e 100644 --- a/tests/blob/sas.test.ts +++ b/tests/blob/sas.test.ts @@ -552,6 +552,58 @@ describe("Shared Access Signature (SAS) authentication", () => { await blob2.beginCopyFromURL(blob1.url); }); + it("Copy blob should work when the source blob declares Content-Encoding: gzip @loki", async () => { + const tmr = new Date(); + tmr.setDate(tmr.getDate() + 1); + + const storageSharedKeyCredential = (serviceClient as any).credential; + + const sas = generateAccountSASQueryParameters( + { + expiresOn: tmr, + ipRange: { start: "0.0.0.0", end: "255.255.255.255" }, + permissions: AccountSASPermissions.parse("rwdlacup"), + protocol: SASProtocol.HttpsAndHttp, + resourceTypes: AccountSASResourceTypes.parse("co").toString(), + services: AccountSASServices.parse("btqf").toString(), + version: "2016-05-31" + }, + storageSharedKeyCredential as StorageSharedKeyCredential + ).toString(); + + const sasURL = `${serviceClient.url}?${sas}`; + const serviceClientWithSAS = new BlobServiceClient( + sasURL, + newPipeline(new AnonymousCredential(), { + // Make sure socket is closed once the operation is done. + keepAliveOptions: { enable: false } + }) + ); + + const containerName = getUniqueName("con"); + const containerClient = serviceClientWithSAS.getContainerClient( + containerName + ); + await containerClient.create(); + + const blobName1 = getUniqueName("blob"); + const blobName2 = getUniqueName("blob"); + const blob1 = containerClient.getBlockBlobClient(blobName1); + const blob2 = containerClient.getBlockBlobClient(blobName2); + + // The body is not actually gzip; the copy source validation request + // must not attempt to decompress it. + await blob1.upload("hello", 5, { + blobHTTPHeaders: { blobContentEncoding: "gzip" } + }); + + // this copy should work + await blob2.beginCopyFromURL(blob1.url); + + const properties = await blob2.getProperties(); + assert.strictEqual(properties.contentEncoding, "gzip"); + }); + it("Copy blob shouldn't work without write permission in account SAS to override an existing blob @loki", async () => { const tmr = new Date(); tmr.setDate(tmr.getDate() + 1); From e601d9abb467942b9d6c5069a75fb5ea678bd2cf Mon Sep 17 00:00:00 2001 From: Andrew Gaul Date: Thu, 23 Jul 2026 17:30:57 -0700 Subject: [PATCH 2/2] Wait for copy completion before asserting in gzip copy test beginCopyFromURL resolves once the poller is created, not when the copy finishes, so poll to completion like the neighboring tests. Co-Authored-By: Claude Fable 5 --- tests/blob/sas.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/blob/sas.test.ts b/tests/blob/sas.test.ts index f7e69b97e..8a9591cd0 100644 --- a/tests/blob/sas.test.ts +++ b/tests/blob/sas.test.ts @@ -598,7 +598,9 @@ describe("Shared Access Signature (SAS) authentication", () => { }); // this copy should work - await blob2.beginCopyFromURL(blob1.url); + const operation = await blob2.beginCopyFromURL(blob1.url); + const copyResponse = await operation.pollUntilDone(); + assert.equal("success", copyResponse.copyStatus); const properties = await blob2.getProperties(); assert.strictEqual(properties.contentEncoding, "gzip");