Skip to content

Commit 856bfb2

Browse files
authored
[ACR] Add validations for blob and manifest download (#36190)
* Add checks for manifest download * Validate Content-Range header in DownloadBlobTo * bug fix * GetManifest tests * Add DownloadBlob tests * pr fb
1 parent a375a11 commit 856bfb2

File tree

6 files changed

+224
-68
lines changed

6 files changed

+224
-68
lines changed

sdk/containerregistry/Azure.Containers.ContainerRegistry/CHANGELOG.md

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,9 @@
22

33
## 1.1.0-beta.10 (Unreleased)
44

5-
### Features Added
6-
75
### Breaking Changes
86

9-
### Bugs Fixed
10-
11-
### Other Changes
7+
- Added sanity check for manifest size at download time - if manifest is bigger than 4MB, `RequestFailedException` will be thrown.
128

139
## 1.1.0-beta.9 (2023-04-11)
1410

sdk/containerregistry/Azure.Containers.ContainerRegistry/src/Content/BlobHelper.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace Azure.Containers.ContainerRegistry
1111
internal class BlobHelper
1212
{
1313
private const string ClientAndServerDigestsDontMatchMessage = "The server-computed digest does not match the client-computed digest.";
14+
internal const string ContentDigestDoesntMatchRequestedMessage = "The digest computed from the downloaded content does not match the requested digest.";
1415
internal const string ManifestDigestDoestMatchRequestedMessage = "The digest of the received manifest does not match the requested digest reference.";
1516

1617
internal static string ComputeDigest(Stream stream)

sdk/containerregistry/Azure.Containers.ContainerRegistry/src/Content/ContainerRegistryContentClient.cs

Lines changed: 86 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ namespace Azure.Containers.ContainerRegistry
2020
public class ContainerRegistryContentClient
2121
{
2222
private const int DefaultChunkSize = 4 * 1024 * 1024; // 4MB
23+
private const int MaxManifestSize = 4 * 1024 * 1024;
24+
25+
private const string InvalidContentLengthMessage = "Missing or invalid 'Content-Length' header in the response.";
26+
private const string InvalidContentRangeMessage = "Missing or invalid 'Content-Range' header in the response.";
2327

2428
private readonly Uri _endpoint;
2529
private readonly string _registryName;
@@ -532,10 +536,22 @@ private static string GetContentRange(long offset, long length)
532536
return FormattableString.Invariant($"{offset}-{endRange}");
533537
}
534538

535-
private static long GetBlobLengthFromContentRange(string contentRange)
539+
private static long GetBlobSize(Response response)
536540
{
537-
string size = contentRange.Split('/')[1];
538-
return long.Parse(size, CultureInfo.InvariantCulture);
541+
if (!response.Headers.TryGetValue("Content-Range", out string contentRange) ||
542+
contentRange == null)
543+
{
544+
throw new RequestFailedException(response.Status, InvalidContentRangeMessage);
545+
}
546+
547+
int index = contentRange.IndexOf('/');
548+
if (!long.TryParse(contentRange.Substring(index + 1), NumberStyles.Integer, CultureInfo.InvariantCulture, out long size) ||
549+
size <= 0)
550+
{
551+
throw new RequestFailedException(response.Status, InvalidContentRangeMessage);
552+
}
553+
554+
return size;
539555
}
540556

541557
// Some streams will throw if you try to access their length so we wrap
@@ -573,26 +589,7 @@ public virtual Response<GetManifestResult> GetManifest(string tagOrDigest, Cance
573589
scope.Start();
574590
try
575591
{
576-
string accept = GetAcceptHeader();
577-
578-
Response<ManifestWrapper> response = _restClient.GetManifest(_repositoryName, tagOrDigest, accept, cancellationToken);
579-
Response rawResponse = response.GetRawResponse();
580-
581-
rawResponse.Headers.TryGetValue("Docker-Content-Digest", out string digest);
582-
rawResponse.Headers.TryGetValue("Content-Type", out string contentType);
583-
584-
var contentDigest = BlobHelper.ComputeDigest(rawResponse.ContentStream);
585-
586-
if (ReferenceIsDigest(tagOrDigest))
587-
{
588-
BlobHelper.ValidateDigest(contentDigest, tagOrDigest, BlobHelper.ManifestDigestDoestMatchRequestedMessage);
589-
}
590-
else
591-
{
592-
BlobHelper.ValidateDigest(contentDigest, digest);
593-
}
594-
595-
return Response.FromValue(new GetManifestResult(digest, contentType, rawResponse.Content), rawResponse);
592+
return GetManifestInternalAsync(tagOrDigest, false, cancellationToken).EnsureCompleted();
596593
}
597594
catch (Exception e)
598595
{
@@ -617,32 +614,39 @@ public virtual async Task<Response<GetManifestResult>> GetManifestAsync(string t
617614
scope.Start();
618615
try
619616
{
620-
string accept = GetAcceptHeader();
617+
return await GetManifestInternalAsync(tagOrDigest, true, cancellationToken).ConfigureAwait(false);
618+
}
619+
catch (Exception e)
620+
{
621+
scope.Failed(e);
622+
throw;
623+
}
624+
}
621625

622-
Response<ManifestWrapper> response = await _restClient.GetManifestAsync(_repositoryName, tagOrDigest, accept, cancellationToken).ConfigureAwait(false);
623-
Response rawResponse = response.GetRawResponse();
626+
private async Task<Response<GetManifestResult>> GetManifestInternalAsync(string reference, bool async, CancellationToken cancellationToken)
627+
{
628+
string accept = GetAcceptHeader();
624629

625-
rawResponse.Headers.TryGetValue("Docker-Content-Digest", out var digest);
626-
rawResponse.Headers.TryGetValue("Content-Type", out string contentType);
630+
Response<ManifestWrapper> response = async ?
631+
await _restClient.GetManifestAsync(_repositoryName, reference, accept, cancellationToken).ConfigureAwait(false) :
632+
_restClient.GetManifest(_repositoryName, reference, accept, cancellationToken);
633+
Response rawResponse = response.GetRawResponse();
627634

628-
var contentDigest = BlobHelper.ComputeDigest(rawResponse.ContentStream);
635+
CheckManifestSize(rawResponse);
629636

630-
if (ReferenceIsDigest(tagOrDigest))
631-
{
632-
BlobHelper.ValidateDigest(contentDigest, tagOrDigest, BlobHelper.ManifestDigestDoestMatchRequestedMessage);
633-
}
634-
else
635-
{
636-
BlobHelper.ValidateDigest(contentDigest, digest);
637-
}
637+
rawResponse.Headers.TryGetValue("Docker-Content-Digest", out string responseHeaderDigest);
638+
rawResponse.Headers.TryGetValue("Content-Type", out string contentType);
638639

639-
return Response.FromValue(new GetManifestResult(digest, contentType, rawResponse.Content), rawResponse);
640-
}
641-
catch (Exception e)
640+
string computedDigest = BlobHelper.ComputeDigest(rawResponse.ContentStream);
641+
642+
BlobHelper.ValidateDigest(computedDigest, responseHeaderDigest);
643+
644+
if (ReferenceIsDigest(reference))
642645
{
643-
scope.Failed(e);
644-
throw;
646+
BlobHelper.ValidateDigest(computedDigest, reference, BlobHelper.ManifestDigestDoestMatchRequestedMessage);
645647
}
648+
649+
return Response.FromValue(new GetManifestResult(responseHeaderDigest, contentType, rawResponse.Content), rawResponse);
646650
}
647651

648652
private static string GetAcceptHeader()
@@ -671,6 +675,30 @@ private static bool ReferenceIsDigest(string reference)
671675
return reference.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase);
672676
}
673677

678+
private static void CheckContentLength(Response response)
679+
{
680+
if (response.Headers.ContentLength == null ||
681+
response.Headers.ContentLength <= 0)
682+
{
683+
throw new RequestFailedException(response.Status, InvalidContentLengthMessage);
684+
}
685+
}
686+
687+
private static void CheckManifestSize(Response response)
688+
{
689+
// This check is to address part of the service threat model.
690+
// If a manifest does not have a proper content length or is too big,
691+
// it indicates a malicious or faulty service and should not be trusted.
692+
CheckContentLength(response);
693+
694+
int? size = response.Headers.ContentLength;
695+
696+
if (size > MaxManifestSize)
697+
{
698+
throw new RequestFailedException(response.Status, "Manifest size is bigger than max allowed size of 4MB.");
699+
}
700+
}
701+
674702
/// <summary>
675703
/// Download a container registry blob.
676704
/// This API is a prefered way to fetch blobs that can fit into memory.
@@ -735,14 +763,17 @@ private async Task<Response<DownloadRegistryBlobResult>> DownloadBlobContentInte
735763
await _blobRestClient.GetBlobAsync(_repositoryName, digest, cancellationToken).ConfigureAwait(false) :
736764
_blobRestClient.GetBlob(_repositoryName, digest, cancellationToken);
737765

766+
Response response = blobResult.GetRawResponse();
767+
CheckContentLength(response);
768+
738769
BinaryData data = async ?
739770
await BinaryData.FromStreamAsync(blobResult.Value, cancellationToken).ConfigureAwait(false) :
740771
BinaryData.FromStream(blobResult.Value);
741772

742773
string contentDigest = BlobHelper.ComputeDigest(data);
743-
BlobHelper.ValidateDigest(contentDigest, digest);
774+
BlobHelper.ValidateDigest(contentDigest, digest, BlobHelper.ContentDigestDoesntMatchRequestedMessage);
744775

745-
return Response.FromValue(new DownloadRegistryBlobResult(digest, data), blobResult.GetRawResponse());
776+
return Response.FromValue(new DownloadRegistryBlobResult(digest, data), response);
746777
}
747778

748779
/// <summary>
@@ -837,6 +868,9 @@ private async Task<Response<DownloadRegistryBlobStreamingResult>> DownloadBlobSt
837868
await _blobRestClient.GetBlobAsync(_repositoryName, digest, cancellationToken).ConfigureAwait(false) :
838869
_blobRestClient.GetBlob(_repositoryName, digest, cancellationToken);
839870

871+
Response response = blobResult.GetRawResponse();
872+
CheckContentLength(response);
873+
840874
// Wrap the response Content in a RetriableStream so we
841875
// can return it before it's finished downloading, but still
842876
// allow retrying if it fails.
@@ -849,7 +883,7 @@ await _blobRestClient.GetBlobAsync(_repositoryName, digest, cancellationToken).C
849883

850884
ValidatingStream stream = new(retriableStream, (int)blobResult.Headers.ContentLength.Value, digest);
851885

852-
return Response.FromValue(new DownloadRegistryBlobStreamingResult(digest, stream), blobResult.GetRawResponse());
886+
return Response.FromValue(new DownloadRegistryBlobStreamingResult(digest, stream), response);
853887
}
854888

855889
/// <summary>
@@ -988,7 +1022,7 @@ private async Task<Response> DownloadBlobToInternalAsync(string digest, Stream d
9881022
using SHA256 sha256 = SHA256.Create();
9891023

9901024
long blobBytes = 0;
991-
long? blobLength = default;
1025+
long? blobSize = default;
9921026

9931027
try
9941028
{
@@ -997,16 +1031,16 @@ private async Task<Response> DownloadBlobToInternalAsync(string digest, Stream d
9971031
do
9981032
{
9991033
// Request a chunk
1000-
long requestLength = blobLength.HasValue ?
1001-
(int)Math.Min(blobLength.Value - blobBytes, options.MaxChunkSize) :
1034+
long requestLength = blobSize.HasValue ?
1035+
(int)Math.Min(blobSize.Value - blobBytes, options.MaxChunkSize) :
10021036
options.MaxChunkSize;
10031037
string requestRange = new HttpRange(blobBytes, requestLength).ToString();
10041038

1005-
var getChunkResponse = async ?
1039+
ResponseWithHeaders<Stream, ContainerRegistryBlobGetChunkHeaders> getChunkResponse = async ?
10061040
await _blobRestClient.GetChunkAsync(_repositoryName, digest, requestRange, cancellationToken).ConfigureAwait(false) :
10071041
_blobRestClient.GetChunk(_repositoryName, digest, requestRange, cancellationToken);
10081042

1009-
blobLength ??= GetBlobLengthFromContentRange(getChunkResponse.Headers.ContentRange);
1043+
blobSize ??= GetBlobSize(getChunkResponse.GetRawResponse());
10101044

10111045
int chunkLength = (int)getChunkResponse.Headers.ContentLength.Value;
10121046
Stream responseStream = getChunkResponse.Value;
@@ -1037,12 +1071,12 @@ await responseStream.ReadAsync(buffer, chunkBytes, chunkLength - chunkBytes, can
10371071
blobBytes += chunkBytes;
10381072
result = getChunkResponse.GetRawResponse();
10391073
}
1040-
while (blobBytes < blobLength.Value);
1074+
while (blobBytes < blobSize.Value);
10411075

10421076
// Complete hash computation.
10431077
sha256.TransformFinalBlock(buffer, 0, 0);
10441078
string computedDigest = BlobHelper.FormatDigest(sha256.Hash);
1045-
BlobHelper.ValidateDigest(computedDigest, digest);
1079+
BlobHelper.ValidateDigest(computedDigest, digest, BlobHelper.ContentDigestDoesntMatchRequestedMessage);
10461080

10471081
if (async)
10481082
{

sdk/containerregistry/Azure.Containers.ContainerRegistry/src/Content/ValidatingStream.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ private void ProcessIncrement(byte[] buffer, int offset, int length)
7070
{
7171
_sha256.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
7272
string computedDigest = BlobHelper.FormatDigest(_sha256.Hash);
73-
BlobHelper.ValidateDigest(computedDigest, _digest);
73+
BlobHelper.ValidateDigest(computedDigest, _digest, BlobHelper.ContentDigestDoesntMatchRequestedMessage);
7474
_validated = true;
7575
}
7676
}

sdk/containerregistry/Azure.Containers.ContainerRegistry/tests/ContainerRegistryContentClientLiveTests.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ public async Task CanDownloadBlobStreaming()
523523

524524
// Act
525525
Response<DownloadRegistryBlobStreamingResult> downloadResult = await client.DownloadBlobStreamingAsync(digest);
526-
Stream downloadedStream = downloadResult.Value.Content;
526+
using Stream downloadedStream = downloadResult.Value.Content;
527527
BinaryData content = BinaryData.FromStream(downloadedStream);
528528

529529
// Assert
@@ -532,7 +532,6 @@ public async Task CanDownloadBlobStreaming()
532532
Assert.AreEqual(data, content.ToArray());
533533

534534
// Clean up
535-
downloadedStream.Dispose();
536535
await client.DeleteBlobAsync(digest);
537536
}
538537

0 commit comments

Comments
 (0)