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
6 changes: 5 additions & 1 deletion src/OrasProject.Oras/Registry/IBlobLocationProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ public interface IBlobLocationProvider
/// </summary>
/// <param name="target">The descriptor identifying the blob</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The blob location URL if a redirect is returned, otherwise null</returns>
/// <returns>
/// The blob location URL if a redirect is returned, otherwise null.
/// A relative Location header is resolved against the request URI, which may yield a URL on the
/// registry host that requires registry credentials rather than a direct storage backend URL.
/// </returns>
/// <exception cref="ArgumentException">Thrown when the provided HttpClient has AllowAutoRedirect enabled</exception>
/// <exception cref="HttpIOException">Thrown when the response is invalid</exception>
/// <exception cref="NotFoundException">Thrown when the blob is not found</exception>
Expand Down
36 changes: 27 additions & 9 deletions src/OrasProject.Oras/Registry/Remote/BlobStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ public async Task<Descriptor> ResolveAsync(
/// </summary>
/// <param name="target">The descriptor identifying the blob</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The blob location URL if a redirect is returned, otherwise null</returns>
/// <returns>
/// The blob location URL if a redirect is returned, otherwise null.
/// A relative Location header is resolved against the request URI, which may yield a URL on the
/// registry host that requires registry credentials rather than a direct storage backend URL.
/// </returns>
/// <exception cref="ArgumentException">Thrown when the provided HttpClient has AllowAutoRedirect enabled</exception>
/// <exception cref="HttpIOException">Thrown when the response is invalid</exception>
/// <exception cref="NotFoundException">Thrown when the blob is not found</exception>
Expand Down Expand Up @@ -341,22 +345,36 @@ public async Task<Descriptor> ResolveAsync(
$"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}: redirect response missing Location header");
}

// Require absolute URI to avoid cross-domain ambiguity.
// This constraint may be removed in the future if needed.
if (!location.IsAbsoluteUri)
// Reject a malformed Location rather than resolving it into a bogus URL,
// consistent with the Link header handling in HttpResponseMessageExtensions.
if (!location.IsWellFormedOriginalString())
{
throw new HttpIOException(HttpRequestError.InvalidResponse,
$"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}: redirect Location header must be an absolute URI");
$"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}: " +
$"invalid redirect location {location.OriginalString}");
}

// Validate HTTPS unless PlainHttp is explicitly allowed
if (!Repository.Options.PlainHttp && location.Scheme != "https")
// A Location header may be a relative reference, in which case it is resolved
// against the request URI, consistent with the upload session Location handling
// in PushAsync and MountAsync.
// Reference: https://www.rfc-editor.org/rfc/rfc9110.html#section-10.2.2
var blobLocation = location.IsAbsoluteUri ? location : new Uri(url, location);

// A blob redirect must point at an HTTP(S) endpoint, so that a Location such as
// file:// or javascript: is never handed back to the caller.
// HTTPS is required unless PlainHttp is explicitly allowed.
var schemeAllowed = blobLocation.Scheme == Uri.UriSchemeHttps ||
(Repository.Options.PlainHttp && blobLocation.Scheme == Uri.UriSchemeHttp);
if (!schemeAllowed)
{
var expectedSchemes = Repository.Options.PlainHttp ? "HTTP or HTTPS" : "HTTPS";
throw new HttpIOException(HttpRequestError.InvalidResponse,
$"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}: redirect location must use HTTPS, got {location.Scheme}://{location.Host}");
$"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}: " +
$"redirect location must use {expectedSchemes}, " +
$"got {blobLocation.Scheme}://{blobLocation.Host}");
}

return location;
return blobLocation;
}

case HttpStatusCode.OK:
Expand Down
236 changes: 202 additions & 34 deletions tests/OrasProject.Oras.Tests/Registry/Remote/RepositoryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1218,9 +1218,210 @@ HttpResponseMessage MockHandlerNoRedirect(HttpRequestMessage req, CancellationTo
Assert.Null(uri); // Should return null when no redirect
}

/// <summary>
/// BlobStore_GetBlobLocationAsync_RelativeLocation tests that a relative redirect Location header
/// is resolved against the request URI, as required by RFC 9110 section 10.2.2.
/// A protocol-relative reference is a relative reference that resolves to a different host,
/// which is accepted because an absolute cross-host location is accepted as well.
/// </summary>
[Theory]
[InlineData("/storage/blobs/test", true, "http://localhost:5000/storage/blobs/test")]
[InlineData("/storage/blobs/test", false, "https://localhost:5000/storage/blobs/test")]
[InlineData("//storage.example.com/blob", false, "https://storage.example.com/blob")]
[InlineData("//storage.example.com:8443/blob", false, "https://storage.example.com:8443/blob")]
[InlineData("?token=xyz", false, "https://localhost:5000/v2/test/blobs/{digest}?token=xyz")]
public async Task BlobStore_GetBlobLocationAsync_RelativeLocation(
string relativeLocation, bool plainHttp, string expectedLocation)
{
var blob = "hello world"u8.ToArray();
var blobDesc = new Descriptor()
{
MediaType = "test",
Digest = ComputeSha256(blob),
Size = blob.Length
};

HttpResponseMessage MockHandlerRelativeRedirect(HttpRequestMessage req, CancellationToken ct = default)
{
var res = new HttpResponseMessage { RequestMessage = req };
if (req.Method != HttpMethod.Get)
{
return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed);
}

if (req.RequestUri?.AbsolutePath == $"/v2/test/blobs/{blobDesc.Digest}")
{
res.StatusCode = HttpStatusCode.TemporaryRedirect;
res.Headers.Location = new Uri(relativeLocation, UriKind.Relative);
res.Headers.Add(_dockerContentDigestHeader, blobDesc.Digest);
return res;
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
}

IRepository repo = new Repository(new RepositoryOptions()
{
Reference = Reference.Parse("localhost:5000/test"),
Client = CustomClient(MockHandlerRelativeRedirect),
PlainHttp = plainHttp,
});

var uri = await repo.GetBlobLocationAsync(blobDesc, new CancellationToken());
Assert.NotNull(uri);
Assert.Equal(expectedLocation.Replace("{digest}", blobDesc.Digest), uri.ToString());
}

/// <summary>
/// BlobStore_GetBlobLocationAsync_RejectsNonHttpScheme tests that a redirect Location using a
/// scheme other than HTTP(S) is rejected, including when PlainHttp is enabled.
/// </summary>
[Theory]
[InlineData("javascript:alert(1)", true)]
[InlineData("javascript:alert(1)", false)]
[InlineData("file:///c:/secret", true)]
public async Task BlobStore_GetBlobLocationAsync_RejectsNonHttpScheme(string location, bool plainHttp)
{
var blob = "hello world"u8.ToArray();
var blobDesc = new Descriptor()
{
MediaType = "test",
Digest = ComputeSha256(blob),
Size = blob.Length
};

HttpResponseMessage MockHandlerNonHttpScheme(HttpRequestMessage req, CancellationToken ct = default)
{
var res = new HttpResponseMessage { RequestMessage = req };
if (req.Method != HttpMethod.Get)
{
return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed);
}

if (req.RequestUri?.AbsolutePath == $"/v2/test/blobs/{blobDesc.Digest}")
{
res.StatusCode = HttpStatusCode.TemporaryRedirect;
res.Headers.Location = new Uri(location);
res.Headers.Add(_dockerContentDigestHeader, blobDesc.Digest);
return res;
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
}

var repo = new Repository(new RepositoryOptions()
{
Reference = Reference.Parse("localhost:5000/test"),
Client = CustomClient(MockHandlerNonHttpScheme),
PlainHttp = plainHttp,
});
var store = new BlobStore(repo);

var exception = await Assert.ThrowsAsync<HttpIOException>(async () =>
await store.GetBlobLocationAsync(blobDesc, new CancellationToken()));
Assert.Contains("HTTPS", exception.Message);
}

/// <summary>
/// BlobStore_GetBlobLocationAsync_RejectsMalformedLocation tests that a malformed redirect Location
/// is rejected rather than resolved into a bogus URL, consistent with the Link header handling in
/// HttpResponseMessageExtensions.
/// </summary>
[Theory]
[InlineData("ht!tp://[bad")]
[InlineData(@"\\evil.example.com\blob")]
public async Task BlobStore_GetBlobLocationAsync_RejectsMalformedLocation(string location)
{
var blob = "hello world"u8.ToArray();
var blobDesc = new Descriptor()
{
MediaType = "test",
Digest = ComputeSha256(blob),
Size = blob.Length
};

HttpResponseMessage MockHandlerMalformed(HttpRequestMessage req, CancellationToken ct = default)
{
var res = new HttpResponseMessage { RequestMessage = req };
if (req.Method != HttpMethod.Get)
{
return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed);
}

if (req.RequestUri?.AbsolutePath == $"/v2/test/blobs/{blobDesc.Digest}")
{
res.StatusCode = HttpStatusCode.TemporaryRedirect;
res.Headers.Location = new Uri(location, UriKind.RelativeOrAbsolute);
res.Headers.Add(_dockerContentDigestHeader, blobDesc.Digest);
return res;
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
}

var repo = new Repository(new RepositoryOptions()
{
Reference = Reference.Parse("localhost:5000/test"),
Client = CustomClient(MockHandlerMalformed),
PlainHttp = true,
});
var store = new BlobStore(repo);

var exception = await Assert.ThrowsAsync<HttpIOException>(async () =>
await store.GetBlobLocationAsync(blobDesc, new CancellationToken()));
Assert.Contains("invalid redirect location", exception.Message);
}

/// <summary>
/// BlobStore_GetBlobLocationAsync_DoesNotContactRedirectTarget tests that the redirect target is
/// never requested: exactly one request is made, and it goes to the registry blob endpoint.
/// </summary>
[Fact]
public async Task BlobStore_GetBlobLocationAsync_DoesNotContactRedirectTarget()
{
var blob = "hello world"u8.ToArray();
var blobDesc = new Descriptor()
{
MediaType = "test",
Digest = ComputeSha256(blob),
Size = blob.Length
};
var redirectLocation = "https://storage.example.com/blob";
var requestedUris = new List<Uri>();

HttpResponseMessage MockHandlerRecording(HttpRequestMessage req, CancellationToken ct = default)
{
requestedUris.Add(req.RequestUri!);
var res = new HttpResponseMessage { RequestMessage = req };
if (req.RequestUri?.AbsolutePath == $"/v2/test/blobs/{blobDesc.Digest}")
{
res.StatusCode = HttpStatusCode.TemporaryRedirect;
res.Headers.Location = new Uri(redirectLocation);
res.Headers.Add(_dockerContentDigestHeader, blobDesc.Digest);
return res;
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
}

IRepository repo = new Repository(new RepositoryOptions()
{
Reference = Reference.Parse("localhost:5000/test"),
Client = CustomClient(MockHandlerRecording),
PlainHttp = true,
});

var uri = await repo.GetBlobLocationAsync(blobDesc, new CancellationToken());
Assert.Equal(redirectLocation, uri?.ToString());

// The blob is never downloaded: the redirect target is not contacted.
var requested = Assert.Single(requestedUris);
Assert.Equal($"http://localhost:5000/v2/test/blobs/{blobDesc.Digest}", requested.ToString());
}

/// <summary>
/// BlobStore_GetBlobLocationAsync_Errors tests GetBlobLocationAsync error scenarios.
/// Tests: 404 Not Found, missing Location header, non-HTTPS location when PlainHttp is false, and relative URI.
/// Tests: 404 Not Found, missing Location header, and non-HTTPS location when PlainHttp is false.
/// </summary>
/// <returns></returns>
[Fact]
Expand Down Expand Up @@ -1316,39 +1517,6 @@ HttpResponseMessage MockHandlerNonHttps(HttpRequestMessage req, CancellationToke
exception = await Assert.ThrowsAsync<HttpIOException>(async () =>
await store.GetBlobLocationAsync(blobDesc, cancellationToken));
Assert.Contains("HTTPS", exception.Message);

// Test case 4: Relative URI in Location header
var relativeLocation = "/storage/blobs/test";
HttpResponseMessage MockHandlerRelative(HttpRequestMessage req, CancellationToken ct = default)
{
var res = new HttpResponseMessage { RequestMessage = req };
if (req.Method != HttpMethod.Get)
{
return new HttpResponseMessage(HttpStatusCode.MethodNotAllowed);
}

if (req.RequestUri?.AbsolutePath == $"/v2/test/blobs/{blobDesc.Digest}")
{
res.StatusCode = HttpStatusCode.TemporaryRedirect;
res.Headers.Location = new Uri(relativeLocation, UriKind.Relative);
res.Headers.Add(_dockerContentDigestHeader, blobDesc.Digest);
return res;
}

return new HttpResponseMessage(HttpStatusCode.NotFound);
}

repo = new Repository(new RepositoryOptions()
{
Reference = Reference.Parse("localhost:5000/test"),
Client = CustomClient(MockHandlerRelative),
PlainHttp = true,
});
store = new BlobStore(repo);

exception = await Assert.ThrowsAsync<HttpIOException>(async () =>
await store.GetBlobLocationAsync(blobDesc, cancellationToken));
Assert.Contains("absolute URI", exception.Message);
}

/// <summary>
Expand Down
Loading