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
4 changes: 4 additions & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 28 additions & 11 deletions src/blob/handlers/BlobHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <source>?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}`,
Expand Down
54 changes: 54 additions & 0 deletions tests/blob/sas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,60 @@ 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
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");
});

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);
Expand Down