diff --git a/ChangeLog.md b/ChangeLog.md index 51cd48fd2..9989aa603 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -9,6 +9,7 @@ General: - Performance improvements for internal metadata access using in-memory metadata store - Fix building failure on Node 22 platform. - Fix * IfMatch for non-existent resource not throwing 412 Precondition Failed +- Fix FilterBlobs returning 500 instead of 200 with optional missing where parameter. ## 2025.07 Version 3.35.0 diff --git a/src/blob/handlers/ContainerHandler.ts b/src/blob/handlers/ContainerHandler.ts index 66c40af6d..9b091abd1 100644 --- a/src/blob/handlers/ContainerHandler.ts +++ b/src/blob/handlers/ContainerHandler.ts @@ -403,7 +403,7 @@ export default class ContainerHandler extends BaseHandler version: BLOB_API_VERSION, date: context.startTime, serviceEndpoint, - where: options.where!, + where: options.where || "", blobs: blobs, clientRequestId: options.requestId, nextMarker: `${nextMarker || ""}` diff --git a/src/blob/handlers/ServiceHandler.ts b/src/blob/handlers/ServiceHandler.ts index c7cb5010e..da54e48fb 100644 --- a/src/blob/handlers/ServiceHandler.ts +++ b/src/blob/handlers/ServiceHandler.ts @@ -406,7 +406,7 @@ export default class ServiceHandler extends BaseHandler version: BLOB_API_VERSION, date: context.startTime, serviceEndpoint, - where: options.where!, + where: options.where || "", blobs: blobs, clientRequestId: options.requestId, nextMarker: `${nextMarker || ""}` diff --git a/tests/blob/apis/service.test.ts b/tests/blob/apis/service.test.ts index cf9e94850..8a829f528 100644 --- a/tests/blob/apis/service.test.ts +++ b/tests/blob/apis/service.test.ts @@ -826,6 +826,132 @@ describe("ServiceAPIs", () => { await containerClient.delete(); }); + + it("filterBlobs without where clause should return empty results @loki @sql", async function () { + // Regression test: calling FilterBlobs (GET /?comp=blobs) without a + // 'where' query parameter used to crash with HTTP 500 because + // options.where was undefined and the response constructor used the + // non-null assertion operator on it. After the fix both + // Service_FilterBlobs and Container_FilterBlobs should return 200 + // with an empty blob list. + const http = require("http"); + const crypto = require("crypto"); + + const host = server.config.host; + const port = server.config.port; + const account = EMULATOR_ACCOUNT_NAME; + const accountKey = EMULATOR_ACCOUNT_KEY; + + function signAndSend( + method: string, + urlPath: string + ): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const now = new Date().toUTCString(); + const headers: Record = { + "x-ms-date": now, + "x-ms-version": "2021-10-04" + }; + + // Build canonicalized headers + const canonHeaders = Object.keys(headers) + .filter((k) => k.startsWith("x-ms-")) + .sort() + .map((k) => `${k}:${headers[k]}\n`) + .join(""); + + // Build canonicalized resource (emulator doubles the account name) + const [pathPart, queryPart] = urlPath.split("?"); + let canonResource = `/${account}/${account}${pathPart}`; + if (queryPart) { + const params: Record = {}; + for (const seg of queryPart.split("&")) { + const [k, v] = seg.split("="); + (params[k.toLowerCase()] = params[k.toLowerCase()] || []).push( + v || "" + ); + } + for (const k of Object.keys(params).sort()) { + canonResource += `\n${k}:${params[k].sort().join(",")}`; + } + } + + const stringToSign = [ + method, + "", // Content-Encoding + "", // Content-Language + "", // Content-Length + "", // Content-MD5 + "", // Content-Type + "", // Date + "", // If-Modified-Since + "", // If-Match + "", // If-None-Match + "", // If-Unmodified-Since + "", // Range + canonHeaders + canonResource + ].join("\n"); + + const sig = crypto + .createHmac("sha256", Buffer.from(accountKey, "base64")) + .update(stringToSign, "utf8") + .digest("base64"); + headers["Authorization"] = `SharedKey ${account}:${sig}`; + + const req = http.request( + { + hostname: host, + port, + path: `/${account}${urlPath}`, + method, + headers + }, + (res: any) => { + let body = ""; + res.on("data", (chunk: any) => (body += chunk)); + res.on("end", () => + resolve({ status: res.statusCode, body }) + ); + } + ); + req.on("error", reject); + req.end(); + }); + } + + // Service-level FilterBlobs without 'where' — should return 200 + const serviceResult = await signAndSend("GET", "/?comp=blobs"); + assert.strictEqual( + serviceResult.status, + 200, + `Service_FilterBlobs without where should return 200, got ${serviceResult.status}: ${serviceResult.body}` + ); + assert.ok( + serviceResult.body.includes(" element" + ); + + // Container-level FilterBlobs without 'where' — create a container first + const containerName = getUniqueName("filtertest"); + const containerClient = serviceClient.getContainerClient(containerName); + await containerClient.create(); + + const containerResult = await signAndSend( + "GET", + `/${containerName}?restype=container&comp=blobs` + ); + assert.strictEqual( + containerResult.status, + 200, + `Container_FilterBlobs without where should return 200, got ${containerResult.status}: ${containerResult.body}` + ); + assert.ok( + containerResult.body.includes(" element" + ); + + await containerClient.delete(); + }); }); describe("ServiceAPIs - secondary location endpoint", () => {