Skip to content
Merged
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
1 change: 1 addition & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Blob:
- Fixed issue #2672 startup failures with legacy persisted data by adding backward-compatible restore for persisted `contentMD5` formats.
- Added CRC-64/NVME transactional checksum support for `StageBlock`, `PutBlock`, `PutBlob`, `AppendBlock`, and `PutPage` (`x-ms-content-crc64`).
- Harden transactional checksum validation for `PutBlob`, `StageBlock`, `AppendBlock`, and `PutPage`: unified MD5/CRC64 validation logic with accurate `InvalidMd5`/`InvalidHeaderValue` (malformed) and `Md5Mismatch`/`Crc64Mismatch` (mismatch) errors, matching real Azure semantics verified against live.
- Implement `PutBlockFromURL` (`Put Block From URL`), which previously returned 501. The source is fetched over loopback so that SAS authentication, `x-ms-source-range`, and the `x-ms-source-if-*` conditions are enforced by the existing download path; unmet source conditions return 412 `SourceConditionNotMet`. As with `CopyBlobFromURL`, only sources on the same Azurite instance are supported.
- Fix `x-ms-blob-content-md5` precedence over `Content-MD5` for `PutBlob` transit integrity verification, matching real Azure behavior.
- Make `CopyBlobFromURL` echo back the source `Content-MD5` when supplied via `x-ms-source-content-md5`, matching real Azure behavior.
- Added support for the `startFrom` query parameter on `List Blobs` (service version `2026-02-06`), which begins a flat or hierarchical listing at the given blob name. Unlike `marker`, which is exclusive, `startFrom` is inclusive, and the two compose when paging a listing that began at `startFrom`. Previously the parameter was accepted but ignored.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,7 @@ Detailed support matrix:
- Copy Blob (Only supports copy within same Azurite instance)
- Abort Copy Blob (Only supports copy within same Azurite instance)
- Copy Blob From URL (Only supports copy within same Azurite instance, only on Loki)
- Put Block From URL (Only supports source within same Azurite instance)
- Access control based on conditional headers
- Following features or REST APIs are NOT supported or limited supported in this release (will support more features per customers feedback in future releases)
- SharedKey Lite
Expand Down
9 changes: 9 additions & 0 deletions src/blob/errors/StorageErrorFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,15 @@ export default class StorageErrorFactory {
);
}

public static getSourceConditionNotMet(contextID: string): StorageError {
return new StorageError(
412,
"SourceConditionNotMet",
"The source condition specified using HTTP conditional header(s) is not met.",
contextID
);
}

public static getInvalidResourceName(contextID: string = ""): StorageError {
return new StorageError(
400,
Expand Down
338 changes: 337 additions & 1 deletion src/blob/handlers/BlockBlobHandler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import axios, { AxiosResponse } from "axios";
import { IncomingMessage } from "http";
import { Agent } from "https";
import { TLSSocket } from "tls";

import { Readable } from "stream";

import { IExtentChunk } from "../../common/persistence/IExtentStore";
import {
convertRawHeadersToMetadata,
getMD5FromString,
Expand All @@ -18,6 +26,48 @@ import {
getTagsFromString
} from "../utils/utils";

/**
* Agents for the loopback self-request stageBlockFromURL makes to read a copy
* source, keyed by the certificate they pin. Shared so requests reuse one
* Agent rather than allocating their own, not for socket reuse: keep-alive
* stays off, taking a loopback handshake per request over an idle socket that
* the server may close mid-reuse, which would surface as a spurious
* CannotVerifyCopySource.
*/
const LOOPBACK_HTTPS_AGENTS = new Map<string, Agent>();

/**
* Build the Agent for a loopback self-request over HTTPS.
*
* The request is pinned to the address and port it arrived on, so the peer is
* necessarily this same server. Rather than turning certificate validation
* off, trust exactly the one certificate this server presents: it is read
* from the accepted socket, so a certificate substituted on the wire is still
* rejected. Azurite is normally run with a self-signed certificate under
* --cert/--key, which no public trust store would accept.
*
* Hostname verification is skipped because the request deliberately targets
* the bound address rather than a name the certificate could carry, and the
* pinned certificate already identifies the peer.
*/
function getLoopbackHttpsAgent(socket: TLSSocket): Agent {
const certificate = socket.getCertificate();
if (certificate === null || !("raw" in certificate)) {
throw new Error("Could not read the local TLS certificate");
}
const der = certificate.raw.toString("base64");
let agent = LOOPBACK_HTTPS_AGENTS.get(der);
if (agent === undefined) {
const pem =
`-----BEGIN CERTIFICATE-----\n` +
`${der.replace(/(.{64})/g, "$1\n")}\n` +
`-----END CERTIFICATE-----\n`;
agent = new Agent({ ca: pem, checkServerIdentity: () => undefined });
LOOPBACK_HTTPS_AGENTS.set(der, agent);
}
return agent;
}

/**
* BlobHandler handles Azure Storage BlockBlob related requests.
*
Expand Down Expand Up @@ -261,7 +311,293 @@ export default class BlockBlobHandler
options: Models.BlockBlobStageBlockFromURLOptionalParams,
context: Context
): Promise<Models.BlockBlobStageBlockFromURLResponse> {
throw new NotImplementedError(context.contextId);
const blobCtx = new BlobStorageContext(context);
const accountName = blobCtx.account!;
const containerName = blobCtx.container!;
const blobName = blobCtx.blob!;
const date = blobCtx.startTime!;

// Put Block From URL carries no request body.
if (contentLength !== 0) {
throw StorageErrorFactory.getInvalidHeaderValue(context.contextId, {
HeaderName: "Content-Length",
HeaderValue: contentLength.toString()
});
}

this.validateBlockId(blockId, blobCtx);

// Reject malformed source checksum headers before fetching anything. The
// shared validator would catch these too, but it reports the names of the
// transactional headers, and its errors would surface only after the
// source had already been read and staged.
if (
options.sourceContentMD5 !== undefined &&
options.sourceContentcrc64 !== undefined
) {
throw StorageErrorFactory.getBothCrc64AndMd5HeaderPresent(
context.contextId
);
}
if (
options.sourceContentMD5 !== undefined &&
options.sourceContentMD5.length !== 16
) {
throw StorageErrorFactory.getInvalidMd5(context.contextId);
}
if (
options.sourceContentcrc64 !== undefined &&
options.sourceContentcrc64.length < 8
) {
throw StorageErrorFactory.getInvalidHeaderValue(context.contextId, {
HeaderName: "x-ms-source-content-crc64",
HeaderValue: Buffer.from(options.sourceContentcrc64).toString("base64")
});
}

await this.metadataStore.checkContainerExist(
context,
accountName,
containerName
);

let url: URL;
try {
url = new URL(sourceUrl);
} catch {
throw StorageErrorFactory.getInvalidHeaderValue(context.contextId, {
HeaderName: "x-ms-copy-source",
HeaderValue: sourceUrl
});
}

// Only sources within the same Azurite instance are supported, as with
// copyFromURL.
// Hostnames compare case-insensitively and new URL() lowercases its
// host, so normalize the client-supplied header before comparing.
const currentServer = (blobCtx.request!.getHeader("Host") || "")
.toLowerCase();
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"
);
}

// The Host header above is client-controlled, so never fetch the
// caller-supplied URL directly; pin the outbound request to the
// loopback address and port this server is actually bound to, keeping
// only the caller's path and query.
const rawRequest = blobCtx.request!.getBodyStream();
if (!(rawRequest instanceof IncomingMessage) ||
rawRequest.socket.localPort === undefined) {
throw StorageErrorFactory.getCannotVerifyCopySource(
context.contextId!,
404,
"The specified resource does not exist"
);
}
const scheme = "encrypted" in rawRequest.socket ? "https" : "http";
// Use the local address this request arrived on rather than a
// hard-coded loopback so non-loopback --blobHost binds keep working;
// IPv6 literals need brackets in URLs.
const localAddress = rawRequest.socket.localAddress || "127.0.0.1";
const localHost = localAddress.includes(":") ?
`[${localAddress}]` : localAddress;
const pinnedUrl =
`${scheme}://${localHost}:${rawRequest.socket.localPort}` +
`${url.pathname}${url.search}`;

// Fetch the source range over loopback so that SAS authentication,
// range handling, and source conditions reuse the download path.
// Preserve the source URL's host so product-style source URLs still
// resolve their account from the Host header; the connection itself
// stays pinned to this server's bound address.
// A block must be staged as the bytes the source actually stores, so ask
// for the body verbatim rather than letting anything in the path apply
// transfer compression.
const headers: { [key: string]: string } = {
host: url.host,
"accept-encoding": "identity"
};
if (options.sourceRange !== undefined) {
// The download path ignores malformed Range headers, which would
// silently stage the entire source blob; reject them up front.
// Compare offsets as BigInt: they are 64-bit, and Number rounds
// above 2^53, which would let an end < start range slip through.
const rangeMatch = /^bytes=(\d+)-(\d*)$/.exec(options.sourceRange);
if (rangeMatch === null ||
(rangeMatch[2] !== "" &&
BigInt(rangeMatch[2]) < BigInt(rangeMatch[1]))) {
throw StorageErrorFactory.getInvalidHeaderValue(context.contextId, {
HeaderName: "x-ms-source-range",
HeaderValue: options.sourceRange
});
}
headers.range = options.sourceRange;
}
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();
}
// Note: unlike the Copy Blob operations, Put Block From URL has no
// x-ms-source-if-tags condition; the generated operation spec does not
// deserialize one.

let sourceResponse: AxiosResponse;
try {
sourceResponse = await axios.get(pinnedUrl, {
headers,
responseType: "stream",
validateStatus: () => true,
// Never decompress. A source blob carries Content-Encoding as a
// stored property, so the download echoes it back even though the
// bytes on the wire are the raw stored ones. Decompressing here
// would stage the decoded content instead of what the source holds,
// and would fail outright when the property does not match the
// bytes.
decompress: false,
// Pin trust to the certificate this server itself presents; see
// getLoopbackHttpsAgent().
httpsAgent: scheme === "https"
? getLoopbackHttpsAgent(rawRequest.socket as TLSSocket)
: undefined
});
} catch (err) {
// Transport-level failures (TLS, connection reset, socket errors) throw
// rather than returning a status. Without this they would escape as a
// bodiless 500 instead of an Azure-shaped error.
this.logger.error(
`BlockBlobHandler:stageBlockFromURL() Failed to read the copy source: ${err}`,
context.contextId
);
throw StorageErrorFactory.getCannotVerifyCopySource(
context.contextId!,
500,
"Could not verify the copy source within the specified time."
);
}

if (sourceResponse.status === 304 || sourceResponse.status === 412) {
sourceResponse.data.destroy();
throw StorageErrorFactory.getSourceConditionNotMet(context.contextId!);
}
if (sourceResponse.status === 404) {
sourceResponse.data.destroy();
throw StorageErrorFactory.getCannotVerifyCopySource(
context.contextId!,
404,
"The specified resource does not exist"
);
}
if (sourceResponse.status !== 200 && sourceResponse.status !== 206) {
sourceResponse.data.destroy();
throw StorageErrorFactory.getCannotVerifyCopySource(
context.contextId!,
sourceResponse.status,
"Could not verify the copy source within the specified time."
);
}

// The status above only means the response headers arrived; the body can
// still fail midway (socket error, connection reset). Map that to the
// same error as a transport failure rather than letting it escape as a
// bodiless 500, and release the source stream on the way out.
let persistency: IExtentChunk;
try {
persistency = await this.extentStore.appendExtent(
sourceResponse.data,
context.contextId
);
} catch (err) {
sourceResponse.data.destroy();
this.logger.error(
`BlockBlobHandler:stageBlockFromURL() Failed to read the copy source body: ${err}`,
context.contextId
);
throw StorageErrorFactory.getCannotVerifyCopySource(
context.contextId!,
500,
"Could not verify the copy source within the specified time."
);
}

// Compare the supplied source checksums against the fetched bytes with
// the same helper Put Block uses. The response always echoes an MD5, so
// that one is always computed. A CRC64 is additionally computed - and so
// additionally echoed - only when no source MD5 was supplied, mirroring
// stageBlock; the two source checksum headers are mutually exclusive, so
// a supplied source MD5 means the caller cannot have asked for CRC64.
// The header shapes were already rejected above, so the only failures
// left are mismatches, which happen after the stream has been read.
// Destroy it regardless so a throw cannot leave the extent handle open.
const stream = await this.extentStore.readExtent(
persistency,
context.contextId
);
let calculatedContentMD5: Uint8Array | undefined;
let calculatedContentCRC64: Uint8Array | undefined;
try {
({ md5: calculatedContentMD5, crc64: calculatedContentCRC64 } =
await computeAndValidateTransactionalChecksums(
stream,
{
md5: options.sourceContentMD5,
crc64: options.sourceContentcrc64
},
context.contextId,
{ md5: true, crc64: options.sourceContentMD5 === undefined }
));
} finally {
(stream as Readable).destroy?.();
}

const block: BlockModel = {
accountName,
containerName,
blobName,
isCommitted: false,
name: blockId,
size: persistency.count,
persistency
};

await this.metadataStore.stageBlock(
context,
block,
options.leaseAccessConditions
);

const response: Models.BlockBlobStageBlockFromURLResponse = {
statusCode: 201,
contentMD5: calculatedContentMD5,
xMsContentCrc64: calculatedContentCRC64,
requestId: blobCtx.contextId,
version: BLOB_API_VERSION,
date,
isServerEncrypted: true,
clientRequestId: options.requestId
};

return response;
}

public async commitBlockList(
Expand Down
Loading
Loading