diff --git a/docs/areas/registries.md b/docs/areas/registries.md
index e454463..937a5a4 100644
--- a/docs/areas/registries.md
+++ b/docs/areas/registries.md
@@ -175,6 +175,12 @@ values stored before the replacement existed, which stay until they are written
or path-relative link stays on the same origin and is followed. A link to another host or port, to `http` when the
listing uses `https`, or with another scheme is not followed and no request is sent to it, so the Docker Hub bearer
token never leaves the configured origin. A link that cannot be parsed is treated the same way.
+- OCI `Link` headers may contain multiple entries or parameters. Entries are split on commas only when the comma is
+ outside an angle-bracketed URI target and a quoted parameter. An entry with an unterminated or otherwise malformed
+ target is skipped; when no entry has a well-formed target with the `next` relation, the listing ends as *succeeded*
+ with the tags read so far and no request is sent. The `next` relation is matched as an exact, case-insensitive
+ relation token of the first `rel` parameter of an entry (later ones are ignored), whether `rel` is quoted
+ (`rel="next last"`) or unquoted (`rel=next`), so relation-like text in another parameter cannot select a page.
- A rejected link makes the listing *failed*, and the entries read before are discarded. The message is
`
stopped: the next-page link pointed to a different origin ()`, for example
`Docker Hub tag listing for 'acme/api' stopped: the next-page link pointed to a different origin (https://attacker.example)`;
diff --git a/src/DockerUpdateGuard/Images/OciRegistryClient.cs b/src/DockerUpdateGuard/Images/OciRegistryClient.cs
index cb3f22d..b756939 100644
--- a/src/DockerUpdateGuard/Images/OciRegistryClient.cs
+++ b/src/DockerUpdateGuard/Images/OciRegistryClient.cs
@@ -2,6 +2,7 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
+using System.Text;
using System.Text.Json;
using DockerUpdateGuard.DockerHub;
@@ -49,6 +50,26 @@ public partial class OciRegistryClient : IRegistryMetadataClient
///
private const string ConfigPropertyName = "config";
+ ///
+ /// Link header scanner state outside quoted parameters and URI targets
+ ///
+ private const char LinkHeaderDefaultState = '\0';
+
+ ///
+ /// Link header scanner state inside an angle-bracketed URI target
+ ///
+ private const char LinkHeaderUriTargetState = '<';
+
+ ///
+ /// Link header scanner state inside a quoted parameter
+ ///
+ private const char LinkHeaderQuotedParameterState = '"';
+
+ ///
+ /// Link header scanner state after an escape character in a quoted parameter
+ ///
+ private const char LinkHeaderEscapedQuotedParameterState = '\\';
+
///
/// Assumed bearer token lifetime when the token response omits "expires_in"
///
@@ -654,26 +675,118 @@ private static RegistryImageConfigurationData ParseImageConfiguration(JsonElemen
return null;
}
- var nextLink = linkValues.SelectMany(value => value.Split(',', StringSplitOptions.RemoveEmptyEntries))
- .Select(value => value.Trim())
- .FirstOrDefault(value => value.Contains("rel=\"next\"", StringComparison.OrdinalIgnoreCase));
-
- if (string.IsNullOrWhiteSpace(nextLink))
+ foreach (var linkValue in linkValues)
{
- return null;
+ foreach (var linkEntry in SplitLinkSegments(linkValue, ',', respectUriTargets: true))
+ {
+ var targetStart = linkEntry.IndexOf('<');
+ var targetEnd = linkEntry.IndexOf('>', targetStart + 1);
+ var nestedTargetStart = linkEntry.IndexOf('<', targetStart + 1);
+
+ if (targetStart < 0
+ || targetEnd <= targetStart
+ || (nestedTargetStart > targetStart && nestedTargetStart < targetEnd))
+ {
+ continue;
+ }
+
+ var linkTarget = linkEntry[(targetStart + 1)..targetEnd];
+
+ if (string.IsNullOrWhiteSpace(linkTarget)
+ || HasNextRelation(linkEntry[(targetEnd + 1)..]) == false)
+ {
+ continue;
+ }
+
+ return linkTarget;
+ }
}
- var start = nextLink.IndexOf('<');
- var end = nextLink.IndexOf('>');
+ return null;
+ }
- if (start < 0 || end <= start)
+ ///
+ /// Split Link header segments without splitting inside quoted parameters or URI targets
+ ///
+ /// Raw Link header value or parameter list
+ /// Segment delimiter
+ /// Whether separators inside angle-bracketed URI targets are retained
+ /// Link header segments
+ private static IEnumerable SplitLinkSegments(string value, char separator, bool respectUriTargets)
+ {
+ var current = new StringBuilder();
+ var state = LinkHeaderDefaultState;
+
+ foreach (var character in value)
{
- return null;
+ if (character == separator && state == LinkHeaderDefaultState)
+ {
+ if (current.Length > 0)
+ {
+ yield return current.ToString();
+ current.Clear();
+ }
+
+ continue;
+ }
+
+ current.Append(character);
+ state = GetNextLinkHeaderScanState(state, character, respectUriTargets);
}
- var linkTarget = nextLink[(start + 1)..end];
+ if (current.Length > 0)
+ {
+ yield return current.ToString();
+ }
+ }
+
+ ///
+ /// Get the Link header segment scanner state after reading a character
+ ///
+ /// Current scanner state
+ /// Current header character
+ /// Whether angle-bracketed URI targets are recognized
+ /// Updated scanner state
+ private static char GetNextLinkHeaderScanState(char state, char character, bool respectUriTargets)
+ {
+ return state switch
+ {
+ LinkHeaderEscapedQuotedParameterState => LinkHeaderQuotedParameterState,
+ LinkHeaderQuotedParameterState when character == '\\' => LinkHeaderEscapedQuotedParameterState,
+ LinkHeaderQuotedParameterState when character == '"' => LinkHeaderDefaultState,
+ LinkHeaderQuotedParameterState => LinkHeaderQuotedParameterState,
+ LinkHeaderUriTargetState when character == '>' => LinkHeaderDefaultState,
+ LinkHeaderUriTargetState => LinkHeaderUriTargetState,
+ LinkHeaderDefaultState when respectUriTargets && character == '<' => LinkHeaderUriTargetState,
+ LinkHeaderDefaultState when character == '"' => LinkHeaderQuotedParameterState,
+ _ => LinkHeaderDefaultState,
+ };
+ }
+
+ ///
+ /// Determine whether a Link entry declares the next relation
+ ///
+ /// Link parameters following the URI target
+ /// True when the entry has an exact next relation
+ private static bool HasNextRelation(string parameterList)
+ {
+ foreach (var parameter in SplitLinkSegments(parameterList, ';', respectUriTargets: false))
+ {
+ var separatorIndex = parameter.IndexOf('=');
+
+ if (separatorIndex <= 0
+ || string.Equals(parameter[..separatorIndex].Trim(), "rel", StringComparison.OrdinalIgnoreCase) == false)
+ {
+ continue;
+ }
+
+ var relationValue = parameter[(separatorIndex + 1)..].Trim().Trim('"');
+
+ return relationValue.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
+ .Any(relation => string.Equals(relation, "next", StringComparison.OrdinalIgnoreCase));
+ }
- return string.IsNullOrWhiteSpace(linkTarget) ? null : linkTarget;
+ return false;
}
///
diff --git a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs
index 5440079..1e2c8fb 100644
--- a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs
+++ b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs
@@ -635,6 +635,344 @@ public async Task OciRegistryClientGetTagsAsyncSameOriginLinkHeaderFollowsLinkAs
}
}
+ ///
+ /// Verify OCI tag listing preserves a comma in a Link target
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncCommaInLinkTargetFollowsCompleteLinkAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ "; rel=\"next\""));
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?last=tag1,tag2&n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [ "2.0.0" ] }""", null));
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/manifests/2.0.0",
+ CreateManifestResponse("sha256:200",
+ """
+ {
+ "schemaVersion": 2,
+ "config": {
+ "digest": "sha256:config200"
+ }
+ }
+ """));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Succeeded,
+ result.Status,
+ "A comma-bearing Link target must allow the tag listing to complete");
+ Assert.IsNotNull(result.Data, "OCI registry tag lookup must return tag data");
+ Assert.AreSequenceEqual(["2.0.0"],
+ result.Data.Select(entity => entity.Tag).ToArray(),
+ "The page selected by the complete Link target must contribute its tags");
+ Assert.ContainsSingle(handler.Requests.Where(request => request.RequestUri == "https://mcr.microsoft.com/v2/mssql/server/tags/list?last=tag1,tag2&n=100"),
+ "The complete comma-bearing continuation URI must be requested");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// Verify OCI tag listing selects the next relation across entries and header values
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncMultipleLinkEntriesAndQuotedCommaSelectNextAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ var response = CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ "; rel=\"prev\", ; title=\"fake; rel=next\"; rel=\"prev\", ; title=\"page, two\"; rel=\"next\"");
+
+ response.Headers.TryAddWithoutValidation("Link", "; rel=\"other\"");
+
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100", response);
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?last=tag1,tag2&n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [ "2.0.0" ] }""", null));
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/manifests/2.0.0",
+ CreateManifestResponse("sha256:200",
+ """
+ {
+ "schemaVersion": 2,
+ "config": {
+ "digest": "sha256:config200"
+ }
+ }
+ """));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Succeeded,
+ result.Status,
+ "Multiple Link entries and header values must still produce a successful tag listing");
+ Assert.IsNotNull(result.Data, "OCI registry tag lookup must return tag data");
+ Assert.AreSequenceEqual(["2.0.0"],
+ result.Data.Select(entity => entity.Tag).ToArray(),
+ "Only the exact next relation must select the continuation page");
+ Assert.ContainsSingle(handler.Requests.Where(request => request.RequestUri == "https://mcr.microsoft.com/v2/mssql/server/tags/list?last=tag1,tag2&n=100"),
+ "The next relation must preserve its complete URI when other entries and quoted commas exist");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// Verify OCI tag listing keeps escaped quotes and commas in a quoted Link parameter
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncEscapedQuoteInQuotedLinkParameterFollowsLinkAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ """; title="Page \"two\", continued"; rel=next"""));
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?last=escaped&n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""", null));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Succeeded,
+ result.Status,
+ "An escaped quote in a quoted Link parameter must not stop the next relation from being read");
+ Assert.ContainsSingle(handler.Requests.Where(request => request.RequestUri == "https://mcr.microsoft.com/v2/mssql/server/tags/list?last=escaped&n=100"),
+ "The continuation following an escaped quote and comma must be requested intact");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// Verify OCI tag listing recognizes an unquoted next relation
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncUnquotedNextRelationFollowsLinkAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ "; rel=next"));
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?last=unquoted&n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""", null));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Succeeded,
+ result.Status,
+ "An unquoted rel=next parameter must select the continuation page");
+ Assert.ContainsSingle(handler.Requests.Where(request => request.RequestUri == "https://mcr.microsoft.com/v2/mssql/server/tags/list?last=unquoted&n=100"),
+ "An unquoted next relation must request its Link target");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// Verify OCI tag listing ignores rel parameters after the first one
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncSecondRelationParameterIsIgnoredAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ "; rel=\"prev\"; rel=\"next\""));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Succeeded,
+ result.Status,
+ "A listing without a first next relation must end as succeeded");
+ Assert.DoesNotContain(request => request.RequestUri == "https://mcr.microsoft.com/v2/mssql/server/tags/list?last=second&n=100",
+ handler.Requests,
+ "A second rel parameter must not select a continuation page");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// Verify OCI tag listing recognizes next as one token of a quoted relation list
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncQuotedRelationListContainingNextFollowsLinkAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ "; rel=\"next last\""));
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?last=relations&n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""", null));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Succeeded,
+ result.Status,
+ "The next token in a quoted relation list must select the continuation page");
+ Assert.ContainsSingle(handler.Requests.Where(request => request.RequestUri == "https://mcr.microsoft.com/v2/mssql/server/tags/list?last=relations&n=100"),
+ "A quoted relation list containing the exact next token must request its Link target");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// Verify OCI tag listing ignores an unterminated URI target
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncUnterminatedLinkTargetDoesNotContinuePaginationAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ "; rel=next"));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Succeeded,
+ result.Status,
+ "An unterminated URI target must end the listing without creating a continuation");
+ Assert.HasCount(1,
+ handler.Requests,
+ "An unterminated URI target must not cause a request to either apparent continuation");
+ Assert.DoesNotContain(request => request.RequestUri.Contains("attacker.example", StringComparison.OrdinalIgnoreCase),
+ handler.Requests,
+ "An unterminated URI target must not send a request to an untrusted origin");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
+ ///
+ /// Verify OCI tag listing rejects a comma-bearing Link target on another origin
+ ///
+ /// Task
+ [TestMethod]
+ public async Task OciRegistryClientGetTagsAsyncForeignCommaBearingLinkHeaderReturnsFailedAsync()
+ {
+ var handler = new SequenceHttpMessageHandler();
+ var httpClient = new HttpClient(handler);
+
+ try
+ {
+ handler.AddResponse("https://mcr.microsoft.com/v2/mssql/server/tags/list?n=100",
+ CreateTagListResponse("""{ "name": "mssql/server", "tags": [] }""",
+ "; rel=next"));
+
+ var client = new OciRegistryClient(httpClient, new TestLogger());
+
+ var result = await client.GetTagsAsync("mcr.microsoft.com",
+ "mssql/server",
+ cancellationToken: CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(ExternalOperationStatus.Failed,
+ result.Status,
+ "A comma-bearing Link target on another origin must fail the tag listing");
+ Assert.AreEqual("Registry tag listing for 'mcr.microsoft.com/mssql/server' stopped: the next-page link pointed to a different origin (https://attacker.example)",
+ result.Message,
+ "The rejected comma-bearing target must be reported by origin only");
+ Assert.DoesNotContain(request => request.RequestUri.Contains("attacker.example", StringComparison.OrdinalIgnoreCase),
+ handler.Requests,
+ "The pagination guard must not send a request to the foreign comma-bearing target");
+ }
+ finally
+ {
+ httpClient.Dispose();
+ handler.Dispose();
+ }
+ }
+
///
/// Verify OCI tag listing ends at a Link header whose target is not enclosed in angle brackets
///