diff --git a/README.md b/README.md index 156d21ac..a8fa018e 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ in a user name or password. | --- | --- | --- | --- | | `Registry` | `docker.io` | Yes | Registry host handled by the Docker Hub integration | | `ApiBaseUrl` | `https://hub.docker.com/` | No | Docker Hub API base address used for registries served by Docker Hub; a mirror or proxy must rewrite next-page links to its own origin, otherwise listings fail | -| `UserName` | none | No | Docker Hub username for authenticated API requests | +| `UserName` | none | No | Docker Hub user name (Docker ID) for authenticated API requests; letters, digits and `.`, `_`, `-` between them, validated at startup | | `Pat` | none | No | Docker Hub personal access token | | `RequestTimeoutSeconds` | `30` | No | Total time per outbound Docker Hub, OCI registry and .NET/nginx release-feed request, including its retries | | `MaxParallelRequests` | `4` | No | Maximum logical request parallelism for registry operations | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 12a707de..4b6d35e1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -431,6 +431,8 @@ it links: or cut where they are display names; the registry request builders refuse `.` and `..` path segments of any reference or digest, and the OCI request builder a registry that is not a host with an optional port ([record 0014](decisions/0014-registry-tag-names-and-digests-are-validated-where-they-are-read.md)). + The configured Docker Hub user name, the path segment of the account listing, must be a Docker Hub user name to pass + validation ([record 0018](decisions/0018-the-docker-hub-user-name-is-validated-as-a-namespace-at-startup.md)). - **Pagination stays on its origin.** `DockerHubClient`, `OciRegistryClient` and `DockerScoutVulnerabilityProvider` follow a next-page link only on the origin of the listing (`Infrastructure\PaginationLinkGuard`), so a remote diff --git a/docs/areas/registries.md b/docs/areas/registries.md index 937a5a47..90da7839 100644 --- a/docs/areas/registries.md +++ b/docs/areas/registries.md @@ -285,7 +285,13 @@ values stored before the replacement existed, which stay until they are written origin of `ApiBaseUrl` (or of an absolute `DockerHub:Registry` that replaces it), fixed at startup like `ApiBaseUrl`, is the only one Docker Hub pagination follows ([Pagination links](#pagination-links)). - Validation of these keys at startup (an invalid value fails startup, see [Hosting](hosting.md)): `Registry` non-empty, `ApiBaseUrl` an absolute `http`/`https` URL, neither `ApiBaseUrl` nor an absolute `Registry` with an `@` - outside its user info ([Hosting](hosting.md), *Validation*), and the ranges above. + outside its user info ([Hosting](hosting.md), *Validation*), the ranges above, and a non-blank `UserName`, trimmed, + which must be a Docker Hub user name: ASCII letters and digits, separated by `.`, `_`, `__` or any run of `-`, at + most 255 characters. Anything else fails with + `'DockerUpdateGuard:DockerHub:UserName' must be a Docker Hub user name: letters and digits, optionally separated by '.', '_' or '-'` + (the failure never contains the value), at startup and on reload. The account listing, which puts the trimmed value + into its request path, thereby never receives `.` or `..` + ([0018](../decisions/0018-the-docker-hub-user-name-is-validated-as-a-namespace-at-startup.md)). ## Limits and errors @@ -326,9 +332,6 @@ values stored before the replacement existed, which stay until they are written attestation manifest, and publication times come only from a top-level `created` field of the manifest, which most registries do not send, so their tags usually have no publication time. Tracked in [#277](https://github.com/LarsLaskowski/DockerUpdateGuard/issues/277). -- The configured `DockerHub:UserName` reaches the path of the account listing without a check, so `.` or `..` would - address another path of the Docker Hub web API. Tracked in - [#363](https://github.com/LarsLaskowski/DockerUpdateGuard/issues/363). ## Related decisions @@ -339,6 +342,8 @@ values stored before the replacement existed, which stay until they are written failure reasons, never an error's own text. - [0014](../decisions/0014-registry-tag-names-and-digests-are-validated-where-they-are-read.md): remote tag names, digests, image references and names are validated or bounded where they are read. +- [0018](../decisions/0018-the-docker-hub-user-name-is-validated-as-a-namespace-at-startup.md): the Docker Hub user + name is validated as a namespace at startup. ## Not here diff --git a/docs/decisions/0018-the-docker-hub-user-name-is-validated-as-a-namespace-at-startup.md b/docs/decisions/0018-the-docker-hub-user-name-is-validated-as-a-namespace-at-startup.md new file mode 100644 index 00000000..9063b353 --- /dev/null +++ b/docs/decisions/0018-the-docker-hub-user-name-is-validated-as-a-namespace-at-startup.md @@ -0,0 +1,45 @@ +# 0018: The Docker Hub user name is validated as a Docker Hub namespace at startup + +- **Status:** Accepted +- **Date:** 2026-11-05 +- **Area:** Registries +- **Source:** Issue #363 +- **Supersedes:** — + +## Context + +`DockerUpdateGuard:DockerHub:UserName` is the namespace of the Docker Hub account listing: +`DockerHubClient.GetRepositoriesAsync` builds `v2/repositories/{name}/` with `Uri.EscapeDataString`, which leaves `.` +alone, and `System.Uri` removes a `.` segment and resolves a `..` segment, so `.` or `..` sent the listing, with the +account's bearer token, to another path of the Docker Hub web API. [0014](0014-registry-tag-names-and-digests-are-validated-where-they-are-read.md) +(option 19) left this configured value to the startup validation instead of a check in the request builder. The same +value is also the login identifier of the Docker Hub web API, the user part of the registry token request's basic +credentials and the Docker Scout login name. + +## Options considered + +1. **Reject only `.` and `..`** — closes the defect, but keeps values that are no Docker Hub name (`a/b`, `me:x`, + `a b`) and fail later at Docker Hub with a less clear message, and a `:` splits the basic credentials of the + registry token request. +2. **Hold the trimmed value to one path component of the Docker reference grammar, ASCII letters of either case, + bounded like a repository path** — every Docker Hub namespace fits it, no form of it is a dot segment, and a + configuration with an upper-case letter keeps starting. Chosen. +3. **Hold it to Docker's sign-up rule for a Docker ID (4 to 30 lower-case letters and digits)** — stricter than any + check the security property needs, and would stop deployments whose value has an upper-case letter or belongs to an + older account outside today's sign-up rule. +4. **Refuse a dot segment in `GetRepositoriesAsync` as well** — the validator also runs on every configuration reload + before the value is read, so the request builder sees no other value; [0014](0014-registry-tag-names-and-digests-are-validated-where-they-are-read.md) + rejected it for the same reason. + +## Decision + +A non-blank `DockerHub:UserName` must, after trimming, be one path component of the Docker reference grammar, with +ASCII letters of either case, of at most 255 characters; any other value fails the configuration validation, at startup and on reload. The rule and +its failure text are in [Registries](../areas/registries.md), *Configuration reload*. + +## Consequences + +- A configuration whose user name is an e-mail address or carries other characters stops starting; the failure names + the key and the expected form, never the value. Whether Docker Hub accepted such a value for login is not known; the + account listing never worked with it. +- Upper-case letters stay accepted although Docker Hub names are lower-case; how Docker Hub treats them is unchanged. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 06203bc8..ecf13518 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -70,4 +70,5 @@ once in the [area document](../areas/README.md) of the record's area, and the re | [0015](0015-the-trivy-scan-target-follows-an-end-of-options-marker.md) | The Trivy scan target follows an end-of-options marker | Accepted | 2026-10-29 | | [0016](0016-the-bundled-trivy-skips-its-update-check-and-telemetry.md) | The bundled Trivy skips its update check and telemetry | Accepted | 2026-10-29 | | [0017](0017-product-prs-may-change-the-squad-settings-file.md) | Product PRs may change the squad settings file | Accepted | 2026-11-01 | +| [0018](0018-the-docker-hub-user-name-is-validated-as-a-namespace-at-startup.md) | The Docker Hub user name is validated as a Docker Hub namespace at startup | Accepted | 2026-11-05 | diff --git a/src/DockerUpdateGuard/Configuration/DockerUpdateGuardOptionsValidator.cs b/src/DockerUpdateGuard/Configuration/DockerUpdateGuardOptionsValidator.cs index 97f13db9..4f005ba9 100644 --- a/src/DockerUpdateGuard/Configuration/DockerUpdateGuardOptionsValidator.cs +++ b/src/DockerUpdateGuard/Configuration/DockerUpdateGuardOptionsValidator.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using DockerUpdateGuard.Images.Helper; + using Microsoft.Extensions.Options; namespace DockerUpdateGuard.Configuration; @@ -103,6 +105,12 @@ private static void ValidateDockerHubOptions(DockerHubOptions options, List 300) { failures.Add($"'{DockerUpdateGuardOptions.SectionName}:DockerHub:RequestTimeoutSeconds' must be between 1 and 300"); diff --git a/src/DockerUpdateGuard/Images/Helper/RegistryReferenceValidator.cs b/src/DockerUpdateGuard/Images/Helper/RegistryReferenceValidator.cs index 057b0029..1222a0dc 100644 --- a/src/DockerUpdateGuard/Images/Helper/RegistryReferenceValidator.cs +++ b/src/DockerUpdateGuard/Images/Helper/RegistryReferenceValidator.cs @@ -69,6 +69,15 @@ internal static partial class RegistryReferenceValidator 100)] private static partial Regex DigestExpression(); + /// + /// Namespace grammar pattern + /// + /// Compiled regular expression + [GeneratedRegex(@"\A[a-zA-Z0-9]+(?:(?:[._]|__|-+)[a-zA-Z0-9]+)*\z", + RegexOptions.CultureInvariant, + 100)] + private static partial Regex NamespaceExpression(); + /// /// Registry grammar pattern /// @@ -103,6 +112,18 @@ public static bool HasDotSegment(string path) || string.Equals(segment, "..", StringComparison.Ordinal)); } + /// + /// Check whether a string is a valid Docker Hub namespace + /// + /// Namespace to check + /// True when the namespace is valid + public static bool IsValidNamespace([NotNullWhen(true)] string? namespaceName) + { + return string.IsNullOrEmpty(namespaceName) == false + && namespaceName.Length <= MaxRepositoryLength + && NamespaceExpression().IsMatch(namespaceName); + } + /// /// Check whether a string is a valid registry host with an optional port /// diff --git a/src/Tests/DockerUpdateGuard.Tests/DockerUpdateGuardOptionsValidatorTests.cs b/src/Tests/DockerUpdateGuard.Tests/DockerUpdateGuardOptionsValidatorTests.cs index 1c974959..dfe6c91a 100644 --- a/src/Tests/DockerUpdateGuard.Tests/DockerUpdateGuardOptionsValidatorTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/DockerUpdateGuardOptionsValidatorTests.cs @@ -818,6 +818,104 @@ public void DockerUpdateGuardOptionsValidatorValidateWithUnusableCertificateOnUn "An enabled instance must be checked for its certificate path whatever its transport"); } + /// + /// Verify a Docker Hub user name outside the namespace grammar fails with the fixed text only + /// + /// Configured Docker Hub user name; null stands for 256 letters + [TestMethod] + [DataRow(".")] + [DataRow("..")] + [DataRow(" .. ")] + [DataRow("...")] + [DataRow(".acme")] + [DataRow("acme.")] + [DataRow("acme/..")] + [DataRow("../acme")] + [DataRow("acme/x")] + [DataRow("%2e%2e")] + [DataRow("acme?x")] + [DataRow("acme#x")] + [DataRow("ac me")] + [DataRow("acme:pw")] + [DataRow("me@example.com")] + [DataRow("a___b")] + [DataRow("acme-")] + [DataRow("\u212Acme")] + [DataRow("ac\u200Bme")] + [DataRow(null)] + public void DockerUpdateGuardOptionsValidatorInvalidDockerHubUserNameReturnsFailure(string? userName) + { + const string failureText = "'DockerUpdateGuard:DockerHub:UserName' must be a Docker Hub user name: letters and digits, optionally separated by '.', '_' or '-'"; + + var validator = CreateAtSignValidator(); + var options = CreateValidOptions(); + var value = userName ?? new string('a', 256); + + options.DockerHub.UserName = value; + + var validationResult = validator.Validate(Options.DefaultName, options); + var failures = validationResult.Failures?.ToArray() ?? []; + + Assert.IsTrue(validationResult.Failed, "A Docker Hub user name outside the namespace grammar must fail validation"); + Assert.AreSequenceEqual([failureText], + failures, + "The user name failure must be the only failure and carry the fixed text"); + } + + /// + /// Verify a blank or valid Docker Hub user name passes validation + /// + /// Configured Docker Hub user name; "255" stands for 255 letters + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow(" ")] + [DataRow("acme")] + [DataRow("Acme")] + [DataRow(" acme ")] + [DataRow("my.org")] + [DataRow("my_org")] + [DataRow("my__org")] + [DataRow("my-org")] + [DataRow("my---org")] + [DataRow("a1b2")] + [DataRow("a")] + [DataRow("255")] + public void DockerUpdateGuardOptionsValidatorValidDockerHubUserNameReturnsSuccess(string? userName) + { + var validator = CreateAtSignValidator(); + var options = CreateValidOptions(); + var value = userName == "255" ? new string('a', 255) : userName; + + options.DockerHub.UserName = value; + + var validationResult = validator.Validate(Options.DefaultName, options); + + Assert.IsTrue(validationResult.Succeeded, + $"A blank or valid Docker Hub user name must pass validation, failures: {string.Join("; ", validationResult.Failures ?? [])}"); + } + + /// + /// Verify a Docker Hub user name surrounded by no-break spaces is trimmed and passes validation + /// + [TestMethod] + public void DockerUpdateGuardOptionsValidatorNoBreakSpaceDockerHubUserNameReturnsSuccess() + { + const string userName = "\u00A0acme\u00A0"; + + Assert.IsTrue(char.IsWhiteSpace(userName[0]), "The user name must start with a no-break space that counts as white space"); + + var validator = CreateAtSignValidator(); + var options = CreateValidOptions(); + + options.DockerHub.UserName = userName; + + var validationResult = validator.Validate(Options.DefaultName, options); + + Assert.IsTrue(validationResult.Succeeded, + $"A Docker Hub user name surrounded by no-break spaces must pass validation, failures: {string.Join("; ", validationResult.Failures ?? [])}"); + } + /// /// Verify a Portainer base URL with an at sign outside its user info fails with the fixed text only ///