mirror of
https://github.com/apple/container.git
synced 2026-10-14 10:09:10 +00:00
This PR fixes a number of issues: - validate descriptor digests before filesystem use - validate container and exec ids before building paths in ManagedContainer - Check for attribute length when consuming extended attributes in ContainerizationEXT4 - Check directory name length when formatting an ext4 fs - Prevent OCI image layout load from reading host files through a symlink - Validate registry auth realm before exchanging credentials --------- Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com> Co-authored-by: John Logan <john_logan@apple.com> Co-authored-by: Raj Aryan Singh <rajaryan_singh@apple.com> Co-authored-by: Raj <realrajaryan@gmail.com>
182 lines
7.4 KiB
Swift
182 lines
7.4 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 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 ContainerizationError
|
|
import Foundation
|
|
import NIO
|
|
import NIOHTTP1
|
|
import Testing
|
|
|
|
@testable import ContainerizationOCI
|
|
|
|
/// `RegistryClient` honours `HTTP_PROXY` from the environment, which would
|
|
/// divert a request aimed at the in-process stub below. Skip rather than fail
|
|
/// when a proxy is configured and does not exempt loopback.
|
|
private var reachesLoopbackDirectly: Bool {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard (env["HTTP_PROXY"] ?? env["http_proxy"]) != nil else {
|
|
return true
|
|
}
|
|
let noProxy = env["NO_PROXY"] ?? env["no_proxy"] ?? ""
|
|
return noProxy.split(separator: ",").contains {
|
|
let entry = $0.trimmingCharacters(in: .whitespaces)
|
|
return entry == "127.0.0.1" || entry == "*"
|
|
}
|
|
}
|
|
|
|
/// Serves one canned HEAD response on loopback and reports the port it bound.
|
|
private final class HeaderStubServer: Sendable {
|
|
let port: Int
|
|
private let channel: Channel
|
|
private let group: MultiThreadedEventLoopGroup
|
|
|
|
static func start(digest: String?) async throws -> HeaderStubServer {
|
|
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
|
let bootstrap = ServerBootstrap(group: group)
|
|
.serverChannelOption(.backlog, value: 16)
|
|
.serverChannelOption(.socketOption(.so_reuseaddr), value: 1)
|
|
.childChannelInitializer { channel in
|
|
channel.eventLoop.makeCompletedFuture {
|
|
try channel.pipeline.syncOperations.configureHTTPServerPipeline(withPipeliningAssistance: false)
|
|
try channel.pipeline.syncOperations.addHandler(Handler(digest: digest))
|
|
}
|
|
}
|
|
|
|
do {
|
|
let bound = try await bootstrap.bind(host: "127.0.0.1", port: 0).get()
|
|
guard let port = bound.localAddress?.port else {
|
|
try? await bound.close().get()
|
|
throw ContainerizationError(.internalError, message: "stub server bound without a port")
|
|
}
|
|
return HeaderStubServer(port: port, channel: bound, group: group)
|
|
} catch {
|
|
try? await group.shutdownGracefully()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private init(port: Int, channel: Channel, group: MultiThreadedEventLoopGroup) {
|
|
self.port = port
|
|
self.channel = channel
|
|
self.group = group
|
|
}
|
|
|
|
func shutdown() async throws {
|
|
try? await channel.close().get()
|
|
try await group.shutdownGracefully()
|
|
}
|
|
|
|
private final class Handler: ChannelInboundHandler, @unchecked Sendable {
|
|
typealias InboundIn = HTTPServerRequestPart
|
|
typealias OutboundOut = HTTPServerResponsePart
|
|
|
|
private let digest: String?
|
|
|
|
init(digest: String?) {
|
|
self.digest = digest
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
guard case .end = unwrapInboundIn(data) else {
|
|
return
|
|
}
|
|
var headers = HTTPHeaders()
|
|
headers.add(name: "Content-Type", value: MediaTypes.imageManifest)
|
|
// A HEAD response advertises the length the body would have had.
|
|
headers.add(name: "Content-Length", value: "512")
|
|
headers.add(name: "Connection", value: "close")
|
|
if let digest {
|
|
headers.add(name: "Docker-Content-Digest", value: digest)
|
|
}
|
|
|
|
let head = HTTPResponseHead(version: .http1_1, status: .ok, headers: headers)
|
|
context.write(wrapOutboundOut(.head(head)), promise: nil)
|
|
|
|
// Bind the context so the completion callback, which runs on this
|
|
// same event loop, can close the channel.
|
|
let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop)
|
|
context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in
|
|
boundContext.value.close(promise: nil)
|
|
}
|
|
}
|
|
|
|
func errorCaught(context: ChannelHandlerContext, error: any Error) {
|
|
context.close(promise: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `resolve` assembles its `Descriptor` out of response headers rather than out
|
|
/// of a JSON body, so `Descriptor`'s decoder — which is what rejects a
|
|
/// traversing digest everywhere else — never runs on it. These tests drive the
|
|
/// real client against a loopback server answering HEAD with a chosen
|
|
/// `Docker-Content-Digest`.
|
|
@Suite(.enabled(if: reachesLoopbackDirectly))
|
|
struct ResolveDigestHeaderTests {
|
|
private static let valid = "sha256:\(String(repeating: "a", count: 64))"
|
|
|
|
private func resolve(digestHeader: String?) async throws -> Descriptor {
|
|
let server = try await HeaderStubServer.start(digest: digestHeader)
|
|
do {
|
|
// Retry options are left off so a rejected digest fails on the first
|
|
// response rather than being replayed.
|
|
let client = RegistryClient(host: "127.0.0.1", scheme: "http", port: server.port)
|
|
let descriptor = try await client.resolve(name: "test/image", tag: "latest")
|
|
try await server.shutdown()
|
|
return descriptor
|
|
} catch {
|
|
try? await server.shutdown()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@Test func acceptsWellFormedDigestHeader() async throws {
|
|
let descriptor = try await resolve(digestHeader: Self.valid)
|
|
#expect(descriptor.digest == Self.valid)
|
|
#expect(descriptor.mediaType == MediaTypes.imageManifest)
|
|
#expect(descriptor.size == 512)
|
|
}
|
|
|
|
/// The descriptor `resolve` returns is the root of a pull: its digest becomes
|
|
/// a URL path segment on the follow-up GET and a content store path component
|
|
/// once the blob lands. A registry — or anything answering as one — must not
|
|
/// be able to put `../` in it.
|
|
@Test(arguments: [
|
|
"sha256:../../../../etc/hosts",
|
|
"sha256:" + String(repeating: "../", count: 64) + "etc/hosts",
|
|
"../../etc/hosts",
|
|
// Bare hex is a usable store path component but not a valid descriptor
|
|
// digest: `fetch` would GET /v2/<name>/manifests/<hex> with no algorithm.
|
|
String(repeating: "a", count: 64),
|
|
"sha256:",
|
|
"",
|
|
"sha256:\(String(repeating: "A", count: 64))",
|
|
"sha512:\(String(repeating: "a", count: 128))",
|
|
"sha256:\(String(repeating: "a", count: 63))",
|
|
])
|
|
func rejectsMalformedDigestHeader(_ digest: String) async throws {
|
|
await #expect(throws: ContainerizationError.self) {
|
|
try await resolve(digestHeader: digest)
|
|
}
|
|
}
|
|
|
|
@Test func rejectsMissingDigestHeader() async throws {
|
|
await #expect(throws: ContainerizationError.self) {
|
|
try await resolve(digestHeader: nil)
|
|
}
|
|
}
|
|
}
|