Skip to content

Implement Put Block From URL - #2681

Open
Andrew Gaul (gaul) wants to merge 1 commit into
Azure:mainfrom
gaul:stage-block-from-url
Open

Implement Put Block From URL#2681
Andrew Gaul (gaul) wants to merge 1 commit into
Azure:mainfrom
gaul:stage-block-from-url

Conversation

@gaul

Copy link
Copy Markdown
Contributor

Stage the block by fetching the copy source over loopback so that SAS authentication, x-ms-source-range, and the x-ms-source-if-* conditions are enforced by the existing download path, then persist it through the same extent flow as Put Block. Only sources on the same Azurite instance are supported, matching copyFromURL. The response carries the MD5 of the staged content and source condition failures return 412 SourceConditionNotMet as on the real service.

Validated with the blockblob test suite and end to end with S3Proxy's native multipart part copy, which previously fell back to streamed emulation on Azurite's 501.

Copilot AI lite review requested due to automatic review settings July 25, 2026 22:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for Block Blob Stage Block From URL (Put Block From URL) in Azurite by downloading the source content via the existing blob download path (so SAS/range/source conditions can be applied) and persisting the staged data through the normal extent + uncommitted-block flow.

Changes:

  • Implements stageBlockFromURL in BlockBlobHandler by fetching the source via HTTP and staging it as an uncommitted block.
  • Introduces a new SourceConditionNotMet (412) storage error for unmet source conditional headers during staging.
  • Expands the block blob API test suite with stageBlockFromURL coverage (range, full copy, unmet condition, missing source).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
tests/blob/apis/blockblob.test.ts Adds tests validating stageBlockFromURL behavior (ranges, full copy, 412 on unmet source condition, 404 on missing source).
src/blob/handlers/BlockBlobHandler.ts Implements the stageBlockFromURL handler: validates input, downloads source data, persists it, and returns MD5.
src/blob/errors/StorageErrorFactory.ts Adds getSourceConditionNotMet() (412) error factory helper.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +277 to +282
const blobCtx = new BlobStorageContext(context);
const accountName = blobCtx.account!;
const containerName = blobCtx.container!;
const blobName = blobCtx.blob!;
const date = blobCtx.startTime!;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +322 to +338
const sourceConditions = options.sourceModifiedAccessConditions || {};
if (sourceConditions.sourceIfMatch !== undefined) {
headers["if-match"] = sourceConditions.sourceIfMatch;
}
if (sourceConditions.sourceIfNoneMatch !== undefined) {
headers["if-none-match"] = sourceConditions.sourceIfNoneMatch;
}
if (sourceConditions.sourceIfModifiedSince !== undefined) {
headers["if-modified-since"] = new Date(
sourceConditions.sourceIfModifiedSince
).toUTCString();
}
if (sourceConditions.sourceIfUnmodifiedSince !== undefined) {
headers["if-unmodified-since"] = new Date(
sourceConditions.sourceIfUnmodifiedSince
).toUTCString();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment thread src/blob/handlers/BlockBlobHandler.ts Outdated
Comment on lines +303 to +314
const currentServer = blobCtx.request!.getHeader("Host") || "";
if (currentServer !== url.host) {
this.logger.error(
`BlockBlobHandler:stageBlockFromURL() Source ${url} is not on the same Azurite instance as target account ${accountName}`,
context.contextId
);
throw StorageErrorFactory.getCannotVerifyCopySource(
context.contextId!,
404,
"The specified resource does not exist"
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copilot AI review requested due to automatic review settings July 25, 2026 22:51
@gaul

Copy link
Copy Markdown
Contributor Author

Addressed the review in aa00b52:

  1. Content-Length — the handler now rejects a nonzero Content-Length with 400 InvalidHeaderValue, since Put Block From URL carries no request body, with a test issuing a raw request with a body.
  2. x-ms-source-if-tags — deliberately not forwarded, with a comment in the handler explaining why: unlike the Copy Blob operations, Put Block From URL has no source tags condition. The field appears in the shared SourceModifiedAccessConditions TypeScript interface, but blockBlobStageBlockFromURLOperationSpec has no Parameters.sourceIfTags, matching the service REST contract, so the deserializer never populates it for this operation.
  3. SSRF via Host — the loopback fetch is no longer built from the caller-supplied URL. The handler takes the actual bound address and port from the request's connection socket and fetches scheme://127.0.0.1:<localPort> plus only the caller's path and query, so a forged Host header can no longer point the fetch at an arbitrary URL. The same-instance Host comparison remains for the 404-on-foreign-source behavior that copyFromURL has.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment on lines +338 to +341
const scheme = "encrypted" in rawRequest.socket ? "https" : "http";
const pinnedUrl =
`${scheme}://127.0.0.1:${rawRequest.socket.localPort}` +
`${url.pathname}${url.search}`;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Comment on lines +346 to +348
if (options.sourceRange !== undefined) {
headers.range = options.sourceRange;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copilot AI review requested due to automatic review settings July 25, 2026 22:59
@gaul

Copy link
Copy Markdown
Contributor Author

Second round addressed in 381b126: the source fetch now pins to the local address the request arrived on (bracketing IPv6 literals) instead of hard-coded 127.0.0.1, so non-loopback --blobHost binds work; and malformed x-ms-source-range values now fail with 400 InvalidHeaderValue up front instead of silently staging the whole source, with tests for both the malformed-range shapes and the reversed-bounds case.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/blob/handlers/BlockBlobHandler.ts Outdated

// Fetch the source range over loopback so that SAS authentication,
// range handling, and source conditions reuse the download path.
const headers: { [key: string]: string } = {};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copilot AI review requested due to automatic review settings July 25, 2026 23:05
@gaul

Copy link
Copy Markdown
Contributor Author

Addressed in 8847aec: the pinned loopback fetch now sends the original source URL host as the Host header — so product-style sources resolve their account through blobStorageContext.middleware exactly as a direct request would — while the TCP connection stays pinned to the server's bound address. Added an end-to-end test where both the destination request and the copy source use product-style account.localhost URLs.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

Comment thread src/blob/handlers/BlockBlobHandler.ts Outdated
Comment on lines +312 to +313
const currentServer = blobCtx.request!.getHeader("Host") || "";
if (currentServer !== url.host) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@gaul

Copy link
Copy Markdown
Contributor Author

Addressed in e665103: the same-instance comparison now lowercases the client-supplied Host header before comparing against the already-lowercased URL host, with a mixed-case Host regression test.

Copilot AI review requested due to automatic review settings July 25, 2026 23:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@gaul

Copy link
Copy Markdown
Contributor Author

Squashed after AI review succeeded.

@jainakanksha-msft

Copy link
Copy Markdown
Member

Andrew Gaul (@gaul) , could you please refresh your PR with main, and address the review comments if any to move this PR forward.

Copilot AI review requested due to automatic review settings August 13, 2026 16:41
@gaul

Copy link
Copy Markdown
Contributor Author

Andrew Gaul (Andrew Gaul (@gaul)) , could you please refresh your PR with main, and address the review comments if any to move this PR forward.

Done.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/blob/handlers/BlockBlobHandler.ts:431

  • sourceContentMD5 validation currently compares raw bytes and throws InvalidOperation on mismatch. This diverges from other checksum validation paths in the codebase and fails to return the expected InvalidMd5 (wrong length) / Md5Mismatch (value mismatch) errors.

Consider validating that sourceContentMD5 is exactly 16 bytes and, on mismatch, throwing StorageErrorFactory.getMd5Mismatch(...) with base64-encoded values (same behavior as computeAndValidateTransactionalChecksums).

    if (options.sourceContentMD5 !== undefined) {
      if (
        !Buffer.from(options.sourceContentMD5).equals(calculatedContentMD5)
      ) {
        throw StorageErrorFactory.getInvalidOperation(

Copilot AI review requested due to automatic review settings August 13, 2026 17:32
@gaul

Copy link
Copy Markdown
Contributor Author

Suppressed comments (1)

Done. Added MD5 validation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/blob/handlers/BlockBlobHandler.ts:426

  • stageBlockFromURL validates only sourceContentMD5. If a client supplies x-ms-source-content-crc64 (options.sourceContentcrc64), it is currently ignored, and the request can incorrectly succeed even when CRC64 mismatches or when both MD5 and CRC64 are provided (Azure rejects both via BothCrc64AndMd5HeaderPresent). Pass both expected checksums into computeAndValidateTransactionalChecksums so header validation + mismatch behavior matches the existing transactional checksum semantics.
    const { md5: calculatedContentMD5 } =
      await computeAndValidateTransactionalChecksums(
        stream,
        { md5: options.sourceContentMD5 },
        context.contextId,

Copilot AI review requested due to automatic review settings August 13, 2026 18:02
@gaul

Copy link
Copy Markdown
Contributor Author

Suppressed comments (1)

Done.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/blob/handlers/BlockBlobHandler.ts:387

  • axios.get(pinnedUrl, ...) can throw (e.g., TLS certificate validation failures when Azurite is running with HTTPS + a self-signed cert, connection errors, etc.). Right now that exception will bypass the status-based mapping below and likely surface as an unhandled 500 rather than a CannotVerifyCopySource/SourceConditionNotMet StorageError. Consider wrapping the request in try/catch and translating failures into a deterministic StorageError (and logging the underlying error) so callers get a consistent Azure-like response.
    const sourceResponse: AxiosResponse = await axios.get(pinnedUrl, {
      headers,
      responseType: "stream",
      validateStatus: () => true
    });

Stage the block by fetching the copy source over loopback so that SAS
authentication, x-ms-source-range, and the x-ms-source-if-* conditions
are enforced by the existing download path, then persist it through
the same extent flow as Put Block.  Only sources on the same Azurite
instance are supported, matching copyFromURL.  The response carries
the MD5 of the staged content and source condition failures return
412 SourceConditionNotMet as on the real service.

Supplied x-ms-source-content-md5 and x-ms-source-content-crc64 values
are validated against the fetched bytes through
computeAndValidateTransactionalChecksums, the same path Put Block
uses, so malformed values return InvalidMd5 or InvalidHeaderValue,
mismatches return Md5Mismatch or Crc64Mismatch, and supplying both
returns BothCrc64AndMd5HeaderPresent.

The source fetch is pinned to the address and port the request arrived
on, so over HTTPS it presents whatever certificate Azurite was started
with.  Skip verification for that self-request, which would otherwise
reject the self-signed certificates Azurite is normally run with and
make the operation unusable under --cert/--key, and translate
transport-level failures into CannotVerifyCopySource rather than
letting them escape as a bodiless 500.

Validated with the blockblob test suite and end to end with S3Proxy's
native multipart part copy, which previously fell back to streamed
emulation on Azurite's 501.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 13, 2026 18:20
@gaul

Copy link
Copy Markdown
Contributor Author

Suppressed comments (1)

Done.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/blob/handlers/BlockBlobHandler.ts:399

  • The internal axios GET may transparently decompress the response when the source blob has a Content-Encoding (e.g., gzip) or when a proxy applies transfer compression based on Accept-Encoding. That would stage different bytes than the source and can also cause unexpected decompression errors. For byte-for-byte correctness, force identity transfer encoding and disable axios decompression for this loopback download.
      sourceResponse = await axios.get(pinnedUrl, {
        headers,
        responseType: "stream",
        validateStatus: () => true,
        // The connection is pinned above to the address and port this very

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants