Skip to content

Commit 34296b5

Browse files
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.
1 parent 25558e6 commit 34296b5

3 files changed

Lines changed: 84 additions & 4 deletions

File tree

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ let package = Package(
194194
"Containerization",
195195
"ContainerizationIO",
196196
.product(name: "NIO", package: "swift-nio"),
197+
.product(name: "NIOHTTP1", package: "swift-nio"),
197198
.product(name: "Crypto", package: "swift-crypto"),
198199
]
199200
),

Sources/ContainerizationOCI/Client/RegistryClient.swift

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,32 @@ public final class RegistryClient: ContentClient {
141141
base.host ?? ""
142142
}
143143

144+
/// Builds the base `HTTPClientRequest` for a registry call, applying the headers
145+
/// that are constant across authentication and retry attempts.
146+
///
147+
/// A `User-Agent` identifying the client is always set so that registries can
148+
/// attribute and, where required, gate requests. The HTTP/1.1 specification only
149+
/// recommends this header, so some servers (and proxies) reject or mishandle
150+
/// requests that omit it. Callers may override it by passing their own
151+
/// `User-Agent` entry in `headers`.
152+
internal func buildRequest(
153+
url: String,
154+
method: HTTPMethod,
155+
headers: [(String, String)]?
156+
) -> HTTPClientRequest {
157+
var request = HTTPClientRequest(url: url)
158+
request.method = method
159+
request.headers.add(name: "User-Agent", value: clientID)
160+
headers?.forEach { (k, v) in
161+
if k.lowercased() == "user-agent" {
162+
request.headers.replaceOrAdd(name: k, value: v)
163+
} else {
164+
request.headers.add(name: k, value: v)
165+
}
166+
}
167+
return request
168+
}
169+
144170
internal func request<T>(
145171
components: URLComponents,
146172
method: HTTPMethod = .GET,
@@ -152,8 +178,7 @@ public final class RegistryClient: ContentClient {
152178
throw ContainerizationError(.invalidArgument, message: "invalid url \(components.path)")
153179
}
154180

155-
var request = HTTPClientRequest(url: path)
156-
request.method = method
181+
var request = buildRequest(url: path, method: method, headers: headers)
157182

158183
var currentToken: TokenResponse?
159184
let token: String? = try await {
@@ -167,8 +192,6 @@ public final class RegistryClient: ContentClient {
167192
request.headers.add(name: "Authorization", value: "\(token)")
168193
}
169194

170-
// Add any arbitrary headers
171-
headers?.forEach { (k, v) in request.headers.add(name: k, value: v) }
172195
var retryCount = 0
173196
var response: HTTPClientResponse?
174197
while true {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import NIOHTTP1
18+
import Testing
19+
20+
@testable import ContainerizationOCI
21+
22+
struct RegistryRequestHeaderTests {
23+
@Test func defaultUserAgentIsSet() throws {
24+
let client = RegistryClient(host: "registry.example.com")
25+
let request = client.buildRequest(url: "https://registry.example.com/v2/", method: .GET, headers: nil)
26+
#expect(request.headers["User-Agent"] == ["containerization-registry-client"])
27+
}
28+
29+
@Test func customClientIDBecomesUserAgent() throws {
30+
let client = RegistryClient(host: "registry.example.com", clientID: "my-tool/1.2.3")
31+
let request = client.buildRequest(url: "https://registry.example.com/v2/", method: .GET, headers: nil)
32+
#expect(request.headers["User-Agent"] == ["my-tool/1.2.3"])
33+
}
34+
35+
@Test func callerSuppliedUserAgentOverridesDefault() throws {
36+
let client = RegistryClient(host: "registry.example.com")
37+
let request = client.buildRequest(
38+
url: "https://registry.example.com/v2/",
39+
method: .GET,
40+
headers: [("User-Agent", "override/9.9")]
41+
)
42+
// The default must not be duplicated when the caller provides one.
43+
#expect(request.headers["User-Agent"] == ["override/9.9"])
44+
}
45+
46+
@Test func userAgentCoexistsWithOtherHeaders() throws {
47+
let client = RegistryClient(host: "registry.example.com")
48+
let request = client.buildRequest(
49+
url: "https://registry.example.com/v2/",
50+
method: .PUT,
51+
headers: [("Content-Type", "application/octet-stream")]
52+
)
53+
#expect(request.headers["User-Agent"] == ["containerization-registry-client"])
54+
#expect(request.headers["Content-Type"] == ["application/octet-stream"])
55+
}
56+
}

0 commit comments

Comments
 (0)