From 34296b5a1b24e42eb821c07da0be80595d68476a Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Sun, 24 May 2026 18:08:44 -0700 Subject: [PATCH] Send a User-Agent header on registry requests Registry requests built by RegistryClient went out without a User-Agent header. The central request() method constructed an HTTPClientRequest and only ever set Authorization plus any caller-supplied headers, and AsyncHTTPClient does not add a default User-Agent of its own, so every registry operation (manifest resolves, blob fetches, token exchanges, and pushes) was anonymous on the wire. HTTP/1.1 only recommends User-Agent rather than requiring it, but in practice some registries and forward proxies reject, rate-limit, or otherwise mishandle requests that omit it, and operators rely on it for attribution and debugging. The client already carried a clientID ("containerization-registry-client" by default), but it was only used as the OAuth client_id form field when fetching tokens, never as an HTTP header. Set the User-Agent from clientID at the single point where requests are constructed so it applies uniformly to every registry call including retries and token fetches. A caller that passes its own User-Agent in the per-request headers still takes precedence, and no duplicate header is emitted. --- Package.swift | 1 + .../Client/RegistryClient.swift | 31 ++++++++-- .../RegistryRequestHeaderTests.swift | 56 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 Tests/ContainerizationOCITests/RegistryRequestHeaderTests.swift diff --git a/Package.swift b/Package.swift index df498005..0fcaa6cc 100644 --- a/Package.swift +++ b/Package.swift @@ -194,6 +194,7 @@ let package = Package( "Containerization", "ContainerizationIO", .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), .product(name: "Crypto", package: "swift-crypto"), ] ), diff --git a/Sources/ContainerizationOCI/Client/RegistryClient.swift b/Sources/ContainerizationOCI/Client/RegistryClient.swift index 14f3142c..dc443f98 100644 --- a/Sources/ContainerizationOCI/Client/RegistryClient.swift +++ b/Sources/ContainerizationOCI/Client/RegistryClient.swift @@ -141,6 +141,32 @@ public final class RegistryClient: ContentClient { base.host ?? "" } + /// Builds the base `HTTPClientRequest` for a registry call, applying the headers + /// that are constant across authentication and retry attempts. + /// + /// A `User-Agent` identifying the client is always set so that registries can + /// attribute and, where required, gate requests. The HTTP/1.1 specification only + /// recommends this header, so some servers (and proxies) reject or mishandle + /// requests that omit it. Callers may override it by passing their own + /// `User-Agent` entry in `headers`. + internal func buildRequest( + url: String, + method: HTTPMethod, + headers: [(String, String)]? + ) -> HTTPClientRequest { + var request = HTTPClientRequest(url: url) + request.method = method + request.headers.add(name: "User-Agent", value: clientID) + headers?.forEach { (k, v) in + if k.lowercased() == "user-agent" { + request.headers.replaceOrAdd(name: k, value: v) + } else { + request.headers.add(name: k, value: v) + } + } + return request + } + internal func request( components: URLComponents, method: HTTPMethod = .GET, @@ -152,8 +178,7 @@ public final class RegistryClient: ContentClient { throw ContainerizationError(.invalidArgument, message: "invalid url \(components.path)") } - var request = HTTPClientRequest(url: path) - request.method = method + var request = buildRequest(url: path, method: method, headers: headers) var currentToken: TokenResponse? let token: String? = try await { @@ -167,8 +192,6 @@ public final class RegistryClient: ContentClient { request.headers.add(name: "Authorization", value: "\(token)") } - // Add any arbitrary headers - headers?.forEach { (k, v) in request.headers.add(name: k, value: v) } var retryCount = 0 var response: HTTPClientResponse? while true { diff --git a/Tests/ContainerizationOCITests/RegistryRequestHeaderTests.swift b/Tests/ContainerizationOCITests/RegistryRequestHeaderTests.swift new file mode 100644 index 00000000..4cf0867a --- /dev/null +++ b/Tests/ContainerizationOCITests/RegistryRequestHeaderTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import NIOHTTP1 +import Testing + +@testable import ContainerizationOCI + +struct RegistryRequestHeaderTests { + @Test func defaultUserAgentIsSet() throws { + let client = RegistryClient(host: "registry.example.com") + let request = client.buildRequest(url: "https://registry.example.com/v2/", method: .GET, headers: nil) + #expect(request.headers["User-Agent"] == ["containerization-registry-client"]) + } + + @Test func customClientIDBecomesUserAgent() throws { + let client = RegistryClient(host: "registry.example.com", clientID: "my-tool/1.2.3") + let request = client.buildRequest(url: "https://registry.example.com/v2/", method: .GET, headers: nil) + #expect(request.headers["User-Agent"] == ["my-tool/1.2.3"]) + } + + @Test func callerSuppliedUserAgentOverridesDefault() throws { + let client = RegistryClient(host: "registry.example.com") + let request = client.buildRequest( + url: "https://registry.example.com/v2/", + method: .GET, + headers: [("User-Agent", "override/9.9")] + ) + // The default must not be duplicated when the caller provides one. + #expect(request.headers["User-Agent"] == ["override/9.9"]) + } + + @Test func userAgentCoexistsWithOtherHeaders() throws { + let client = RegistryClient(host: "registry.example.com") + let request = client.buildRequest( + url: "https://registry.example.com/v2/", + method: .PUT, + headers: [("Content-Type", "application/octet-stream")] + ) + #expect(request.headers["User-Agent"] == ["containerization-registry-client"]) + #expect(request.headers["Content-Type"] == ["application/octet-stream"]) + } +}