From 497a586d7bef6e0827be8d4c9c042296cf4e8c1c Mon Sep 17 00:00:00 2001 From: Serag Eldin Ahmed Date: Sun, 25 Oct 2026 09:30:30 +0300 Subject: [PATCH 1/3] [Scanning] Preserve commas in OCI pagination links --- .../Images/OciRegistryClient.cs | 176 ++++++++++++++++-- .../OciRegistryClientTests.cs | 107 +++++++++++ 2 files changed, 272 insertions(+), 11 deletions(-) diff --git a/src/DockerUpdateGuard/Images/OciRegistryClient.cs b/src/DockerUpdateGuard/Images/OciRegistryClient.cs index cb3f22d..334ba4b 100644 --- a/src/DockerUpdateGuard/Images/OciRegistryClient.cs +++ b/src/DockerUpdateGuard/Images/OciRegistryClient.cs @@ -654,26 +654,180 @@ 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)); + foreach (var linkValue in linkValues) + { + foreach (var linkEntry in SplitLinkHeaderEntries(linkValue)) + { + if (HasNextRelation(linkEntry) == false) + { + continue; + } + + var start = linkEntry.IndexOf('<'); + var end = linkEntry.IndexOf('>'); + + if (start < 0 || end <= start) + { + continue; + } + + var linkTarget = linkEntry[(start + 1)..end]; + + if (string.IsNullOrWhiteSpace(linkTarget) == false) + { + return linkTarget; + } + } + } + + return null; + } + + /// + /// Split Link header entries without splitting inside a URI target or quoted parameter + /// + /// Raw Link header value + /// Link entries + private static IEnumerable SplitLinkHeaderEntries(string linkValue) + { + var current = new List(); + var inUriTarget = false; + var inQuotes = false; + var escaped = false; - if (string.IsNullOrWhiteSpace(nextLink)) + foreach (var character in linkValue) { - return null; + UpdateLinkHeaderState(character, ref inUriTarget, ref inQuotes, ref escaped); + + if (character == ',' && inUriTarget == false && inQuotes == false) + { + if (current.Count > 0) + { + yield return new string([.. current]); + current.Clear(); + } + + continue; + } + + current.Add(character); } - var start = nextLink.IndexOf('<'); - var end = nextLink.IndexOf('>'); + if (current.Count > 0) + { + yield return new string([.. current]); + } + } - if (start < 0 || end <= start) + /// + /// Update the state used while parsing a Link header + /// + /// Current header character + /// Whether parsing an angle-bracketed URI target + /// Whether parsing a quoted parameter + /// Whether the previous character escaped the current character + private static void UpdateLinkHeaderState(char character, + ref bool inUriTarget, + ref bool inQuotes, + ref bool escaped) + { + if (character == '<' && inQuotes == false) { - return null; + inUriTarget = true; + } + else if (character == '>' && inQuotes == false) + { + inUriTarget = false; + } + else if (character == '"' && inUriTarget == false && escaped == false) + { + inQuotes = inQuotes == false; + } + + escaped = character == '\\' && inQuotes && escaped == false; + } + + /// + /// Determine whether a Link entry declares the next relation + /// + /// Link entry + /// True when the entry has an exact next relation + private static bool HasNextRelation(string linkEntry) + { + var end = linkEntry.IndexOf('>'); + + if (end < 0) + { + return false; + } + + foreach (var parameter in SplitLinkParameters(linkEntry, end + 1)) + { + var separatorIndex = parameter.IndexOf('='); + + if (separatorIndex <= 0 + || string.Equals(parameter[..separatorIndex].Trim(), "rel", StringComparison.OrdinalIgnoreCase) == false) + { + continue; + } + + var relationValue = parameter[(separatorIndex + 1)..].Trim().Trim('"'); + + if (relationValue.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Any(relation => string.Equals(relation, "next", StringComparison.OrdinalIgnoreCase))) + { + return true; + } } - var linkTarget = nextLink[(start + 1)..end]; + return false; + } + + /// + /// Split Link parameters without splitting inside a quoted value + /// + /// Link entry + /// Parameter start index + /// Link parameters + private static IEnumerable SplitLinkParameters(string linkEntry, int start) + { + var current = new List(); + var inQuotes = false; + var escaped = false; + + for (var index = start; index < linkEntry.Length; index++) + { + var character = linkEntry[index]; + + if (character == '"' && escaped == false) + { + inQuotes = inQuotes == false; + } + + if (character == '\\' && inQuotes && escaped == false) + { + escaped = true; + } + else + { + escaped = false; + } + + if (character == ';' && inQuotes == false) + { + yield return new string([.. current]); + current.Clear(); + + continue; + } + + current.Add(character); + } - return string.IsNullOrWhiteSpace(linkTarget) ? null : linkTarget; + if (current.Count > 0) + { + yield return new string([.. current]); + } } /// diff --git a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs index 5440079..4b92539 100644 --- a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs @@ -635,6 +635,113 @@ 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 ends at a Link header whose target is not enclosed in angle brackets /// From c76aa3437d46cada8281cdc5b2dfdad8412fbd86 Mon Sep 17 00:00:00 2001 From: SerageLdin Date: Sun, 25 Oct 2026 13:38:17 +0300 Subject: [PATCH 2/3] [Scanning] Simplify OCI Link header parsing --- docs/areas/registries.md | 4 + .../Images/OciRegistryClient.cs | 176 +++++++--------- .../OciRegistryClientTests.cs | 194 ++++++++++++++++++ 3 files changed, 267 insertions(+), 107 deletions(-) diff --git a/docs/areas/registries.md b/docs/areas/registries.md index e454463..2955b0d 100644 --- a/docs/areas/registries.md +++ b/docs/areas/registries.md @@ -175,6 +175,10 @@ 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 unterminated or otherwise malformed target is not + followed. The `next` relation is matched as an exact, case-insensitive relation token, 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 334ba4b..e210a7d 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" /// @@ -656,27 +677,28 @@ private static RegistryImageConfigurationData ParseImageConfiguration(JsonElemen foreach (var linkValue in linkValues) { - foreach (var linkEntry in SplitLinkHeaderEntries(linkValue)) + foreach (var linkEntry in SplitLinkSegments(linkValue, ',', respectUriTargets: true)) { - if (HasNextRelation(linkEntry) == false) + 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 start = linkEntry.IndexOf('<'); - var end = linkEntry.IndexOf('>'); + var linkTarget = linkEntry[(targetStart + 1)..targetEnd]; - if (start < 0 || end <= start) + if (string.IsNullOrWhiteSpace(linkTarget) + || HasNextRelation(linkEntry[(targetEnd + 1)..]) == false) { continue; } - var linkTarget = linkEntry[(start + 1)..end]; - - if (string.IsNullOrWhiteSpace(linkTarget) == false) - { - return linkTarget; - } + return linkTarget; } } @@ -684,84 +706,71 @@ private static RegistryImageConfigurationData ParseImageConfiguration(JsonElemen } /// - /// Split Link header entries without splitting inside a URI target or quoted parameter + /// Split Link header segments without splitting inside quoted parameters or URI targets /// - /// Raw Link header value - /// Link entries - private static IEnumerable SplitLinkHeaderEntries(string linkValue) + /// 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 List(); - var inUriTarget = false; - var inQuotes = false; - var escaped = false; + var current = new StringBuilder(); + var state = LinkHeaderDefaultState; - foreach (var character in linkValue) + foreach (var character in value) { - UpdateLinkHeaderState(character, ref inUriTarget, ref inQuotes, ref escaped); - - if (character == ',' && inUriTarget == false && inQuotes == false) + if (character == separator && state == LinkHeaderDefaultState) { - if (current.Count > 0) + if (current.Length > 0) { - yield return new string([.. current]); + yield return current.ToString(); current.Clear(); } continue; } - current.Add(character); + current.Append(character); + state = GetNextLinkHeaderScanState(state, character, respectUriTargets); } - if (current.Count > 0) + if (current.Length > 0) { - yield return new string([.. current]); + yield return current.ToString(); } } /// - /// Update the state used while parsing a Link header + /// Get the Link header segment scanner state after reading a character /// + /// Current scanner state /// Current header character - /// Whether parsing an angle-bracketed URI target - /// Whether parsing a quoted parameter - /// Whether the previous character escaped the current character - private static void UpdateLinkHeaderState(char character, - ref bool inUriTarget, - ref bool inQuotes, - ref bool escaped) + /// Whether angle-bracketed URI targets are recognized + /// Updated scanner state + private static char GetNextLinkHeaderScanState(char state, char character, bool respectUriTargets) { - if (character == '<' && inQuotes == false) - { - inUriTarget = true; - } - else if (character == '>' && inQuotes == false) - { - inUriTarget = false; - } - else if (character == '"' && inUriTarget == false && escaped == false) - { - inQuotes = inQuotes == false; - } - - escaped = character == '\\' && inQuotes && escaped == false; + 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 entry + /// Link parameters following the URI target /// True when the entry has an exact next relation - private static bool HasNextRelation(string linkEntry) + private static bool HasNextRelation(string parameterList) { - var end = linkEntry.IndexOf('>'); - - if (end < 0) - { - return false; - } - - foreach (var parameter in SplitLinkParameters(linkEntry, end + 1)) + foreach (var parameter in SplitLinkSegments(parameterList, ';', respectUriTargets: false)) { var separatorIndex = parameter.IndexOf('='); @@ -783,53 +792,6 @@ private static bool HasNextRelation(string linkEntry) return false; } - /// - /// Split Link parameters without splitting inside a quoted value - /// - /// Link entry - /// Parameter start index - /// Link parameters - private static IEnumerable SplitLinkParameters(string linkEntry, int start) - { - var current = new List(); - var inQuotes = false; - var escaped = false; - - for (var index = start; index < linkEntry.Length; index++) - { - var character = linkEntry[index]; - - if (character == '"' && escaped == false) - { - inQuotes = inQuotes == false; - } - - if (character == '\\' && inQuotes && escaped == false) - { - escaped = true; - } - else - { - escaped = false; - } - - if (character == ';' && inQuotes == false) - { - yield return new string([.. current]); - current.Clear(); - - continue; - } - - current.Add(character); - } - - if (current.Count > 0) - { - yield return new string([.. current]); - } - } - /// /// Create a structured failure result from an HTTP response /// diff --git a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs index 4b92539..16dc399 100644 --- a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs @@ -742,6 +742,200 @@ public async Task OciRegistryClientGetTagsAsyncMultipleLinkEntriesAndQuotedComma } } + /// + /// 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 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 /// From 69dbdc76f494e9cd5376d89e6155edcdc840bdd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:02:29 +0000 Subject: [PATCH 3/3] [Scanning] Honor only the first rel parameter and clarify malformed links Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_018YRorFpvB4ok8utE3yixEe --- docs/areas/registries.md | 6 ++- .../Images/OciRegistryClient.cs | 7 +--- .../OciRegistryClientTests.cs | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/areas/registries.md b/docs/areas/registries.md index 2955b0d..937a5a4 100644 --- a/docs/areas/registries.md +++ b/docs/areas/registries.md @@ -176,8 +176,10 @@ values stored before the replacement existed, which stay until they are written 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 unterminated or otherwise malformed target is not - followed. The `next` relation is matched as an exact, case-insensitive relation token, whether `rel` is quoted + 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 diff --git a/src/DockerUpdateGuard/Images/OciRegistryClient.cs b/src/DockerUpdateGuard/Images/OciRegistryClient.cs index e210a7d..b756939 100644 --- a/src/DockerUpdateGuard/Images/OciRegistryClient.cs +++ b/src/DockerUpdateGuard/Images/OciRegistryClient.cs @@ -782,11 +782,8 @@ private static bool HasNextRelation(string parameterList) var relationValue = parameter[(separatorIndex + 1)..].Trim().Trim('"'); - if (relationValue.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) - .Any(relation => string.Equals(relation, "next", StringComparison.OrdinalIgnoreCase))) - { - return true; - } + return relationValue.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + .Any(relation => string.Equals(relation, "next", StringComparison.OrdinalIgnoreCase)); } return false; diff --git a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs index 16dc399..1e2c8fb 100644 --- a/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/OciRegistryClientTests.cs @@ -818,6 +818,43 @@ public async Task OciRegistryClientGetTagsAsyncUnquotedNextRelationFollowsLinkAs } } + /// + /// 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 ///