Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions docs/areas/registries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
<!-- project:end index -->
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis;

using DockerUpdateGuard.Images.Helper;

using Microsoft.Extensions.Options;

namespace DockerUpdateGuard.Configuration;
Expand Down Expand Up @@ -103,6 +105,12 @@ private static void ValidateDockerHubOptions(DockerHubOptions options, List<stri
failures.Add(CreateAtSignFailure($"'{DockerUpdateGuardOptions.SectionName}:DockerHub:Registry'"));
}

if (string.IsNullOrWhiteSpace(options.UserName) == false
&& RegistryReferenceValidator.IsValidNamespace(options.UserName.Trim()) == false)
{
failures.Add($"'{DockerUpdateGuardOptions.SectionName}:DockerHub:UserName' must be a Docker Hub user name: letters and digits, optionally separated by '.', '_' or '-'");
}

if (options.RequestTimeoutSeconds is <= 0 or > 300)
{
failures.Add($"'{DockerUpdateGuardOptions.SectionName}:DockerHub:RequestTimeoutSeconds' must be between 1 and 300");
Expand Down
21 changes: 21 additions & 0 deletions src/DockerUpdateGuard/Images/Helper/RegistryReferenceValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,15 @@ internal static partial class RegistryReferenceValidator
100)]
private static partial Regex DigestExpression();

/// <summary>
/// Namespace grammar pattern
/// </summary>
/// <returns>Compiled regular expression</returns>
[GeneratedRegex(@"\A[a-zA-Z0-9]+(?:(?:[._]|__|-+)[a-zA-Z0-9]+)*\z",
RegexOptions.CultureInvariant,
100)]
private static partial Regex NamespaceExpression();

/// <summary>
/// Registry grammar pattern
/// </summary>
Expand Down Expand Up @@ -103,6 +112,18 @@ public static bool HasDotSegment(string path)
|| string.Equals(segment, "..", StringComparison.Ordinal));
}

/// <summary>
/// Check whether a string is a valid Docker Hub namespace
/// </summary>
/// <param name="namespaceName">Namespace to check</param>
/// <returns>True when the namespace is valid</returns>
public static bool IsValidNamespace([NotNullWhen(true)] string? namespaceName)
{
return string.IsNullOrEmpty(namespaceName) == false
&& namespaceName.Length <= MaxRepositoryLength
&& NamespaceExpression().IsMatch(namespaceName);
}

/// <summary>
/// Check whether a string is a valid registry host with an optional port
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,104 @@ public void DockerUpdateGuardOptionsValidatorValidateWithUnusableCertificateOnUn
"An enabled instance must be checked for its certificate path whatever its transport");
}

/// <summary>
/// Verify a Docker Hub user name outside the namespace grammar fails with the fixed text only
/// </summary>
/// <param name="userName">Configured Docker Hub user name; null stands for 256 letters</param>
[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("[email protected]")]
[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");
}

/// <summary>
/// Verify a blank or valid Docker Hub user name passes validation
/// </summary>
/// <param name="userName">Configured Docker Hub user name; "255" stands for 255 letters</param>
[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 ?? [])}");
}

/// <summary>
/// Verify a Docker Hub user name surrounded by no-break spaces is trimmed and passes validation
/// </summary>
[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 ?? [])}");
}

/// <summary>
/// Verify a Portainer base URL with an at sign outside its user info fails with the fixed text only
/// </summary>
Expand Down
Loading