Files
container/Tests/ContainerizationOCITests/ResolveDigestHeaderTests.swift
T
c0185aea5c Improve security across containerization (#898)
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>
2026-08-28 13:22:06 -07:00

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)
}
}
}