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 ///