Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
25 changes: 14 additions & 11 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,21 @@ 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)
{
throw new HttpIOException(HttpRequestError.InvalidResponse,
$"{response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}: redirect Location header must be an absolute URI");
}
// 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);

// Validate HTTPS unless PlainHttp is explicitly allowed
if (!Repository.Options.PlainHttp && location.Scheme != "https")
if (!Repository.Options.PlainHttp && blobLocation.Scheme != "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 HTTPS, got {blobLocation.Scheme}://{blobLocation.Host}");
}

return location;
return blobLocation;
}

case HttpStatusCode.OK:
Expand Down
88 changes: 54 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,62 @@ 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("?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_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 +1369,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