Skip to content

Commit aa00b52

Browse files
gaulclaude
andcommitted
Address review feedback for Put Block From URL
Reject requests with a nonzero Content-Length since Put Block From URL carries no body, and pin the loopback source fetch to the address and port the server is actually bound to, taken from the connection socket, so the client-controlled Host header can no longer redirect the fetch to an arbitrary URL. x-ms-source-if-tags is deliberately not forwarded: unlike the Copy Blob operations, Put Block From URL has no source tags condition and the generated operation spec does not deserialize one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0f1bbd1 commit aa00b52

2 files changed

Lines changed: 62 additions & 1 deletion

File tree

src/blob/handlers/BlockBlobHandler.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios, { AxiosResponse } from "axios";
2+
import { IncomingMessage } from "http";
23

34
import { convertRawHeadersToMetadata } from "../../common/utils/utils";
45
import {
@@ -280,6 +281,14 @@ export default class BlockBlobHandler
280281
const blobName = blobCtx.blob!;
281282
const date = blobCtx.startTime!;
282283

284+
// Put Block From URL carries no request body.
285+
if (contentLength !== 0) {
286+
throw StorageErrorFactory.getInvalidHeaderValue(context.contextId, {
287+
HeaderName: "Content-Length",
288+
HeaderValue: contentLength.toString()
289+
});
290+
}
291+
283292
this.validateBlockId(blockId, blobCtx);
284293

285294
await this.metadataStore.checkContainerExist(
@@ -313,6 +322,24 @@ export default class BlockBlobHandler
313322
);
314323
}
315324

325+
// The Host header above is client-controlled, so never fetch the
326+
// caller-supplied URL directly; pin the outbound request to the
327+
// loopback address and port this server is actually bound to, keeping
328+
// only the caller's path and query.
329+
const rawRequest = blobCtx.request!.getBodyStream();
330+
if (!(rawRequest instanceof IncomingMessage) ||
331+
rawRequest.socket.localPort === undefined) {
332+
throw StorageErrorFactory.getCannotVerifyCopySource(
333+
context.contextId!,
334+
404,
335+
"The specified resource does not exist"
336+
);
337+
}
338+
const scheme = "encrypted" in rawRequest.socket ? "https" : "http";
339+
const pinnedUrl =
340+
`${scheme}://127.0.0.1:${rawRequest.socket.localPort}` +
341+
`${url.pathname}${url.search}`;
342+
316343
// Fetch the source range over loopback so that SAS authentication,
317344
// range handling, and source conditions reuse the download path.
318345
const headers: { [key: string]: string } = {};
@@ -336,8 +363,11 @@ export default class BlockBlobHandler
336363
sourceConditions.sourceIfUnmodifiedSince
337364
).toUTCString();
338365
}
366+
// Note: unlike the Copy Blob operations, Put Block From URL has no
367+
// x-ms-source-if-tags condition; the generated operation spec does not
368+
// deserialize one.
339369

340-
const sourceResponse: AxiosResponse = await axios.get(sourceUrl, {
370+
const sourceResponse: AxiosResponse = await axios.get(pinnedUrl, {
341371
headers,
342372
responseType: "stream",
343373
validateStatus: () => true

tests/blob/apis/blockblob.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,37 @@ describe("BlockBlobAPIs", () => {
341341
assert.ok(response.data.includes("SourceConditionNotMet"));
342342
});
343343

344+
it("stageBlockFromURL rejects a request body @loki @sql", async () => {
345+
const content = "HelloWorldFromSourceBlob";
346+
const sourceClient = containerClient.getBlockBlobClient(
347+
getUniqueName("source")
348+
);
349+
await sourceClient.upload(content, content.length);
350+
const sourceUrl = await sourceClient.generateSasUrl({
351+
permissions: BlobSASPermissions.parse("r"),
352+
expiresOn: new Date(Date.now() + 60 * 60 * 1000)
353+
});
354+
const destinationUrl = await blockBlobClient.generateSasUrl({
355+
permissions: BlobSASPermissions.parse("rw"),
356+
expiresOn: new Date(Date.now() + 60 * 60 * 1000)
357+
});
358+
359+
const response = await axios.put(
360+
destinationUrl +
361+
"&comp=block&blockid=" +
362+
encodeURIComponent(base64encode("1")),
363+
"unexpected body",
364+
{
365+
headers: {
366+
"x-ms-copy-source": sourceUrl
367+
},
368+
validateStatus: () => true
369+
}
370+
);
371+
assert.deepStrictEqual(response.status, 400);
372+
assert.ok(response.data.includes("InvalidHeaderValue"));
373+
});
374+
344375
it("stageBlockFromURL from a missing source returns 404 @loki @sql", async () => {
345376
const missingClient = containerClient.getBlockBlobClient(
346377
getUniqueName("missing")

0 commit comments

Comments
 (0)