mirror of
https://github.com/apple/container.git
synced 2026-09-13 11:15:41 +00:00
* Admit only valid OCI layout files when loading image. - Adds `AdmissionMapper` protocol for validating archive member paths and normalizing them to relative paths under the extraction root directory. - Renames `Reader.swift` to `ArchiveReader.swift` to match type name. - Rework `TempDir` to address `NSString.utf8String` deprecation warning in Swift 6.2.3. - Rework `ArchiveReader.extractContent()` to use an `AdmissionMapper` to validate and remap archive members before extracting. - Adds `IdentityAdmissionWrapper` for naive extraction. - Adds `NoSymlinkAdmissionWrapper` that only extracts regular files and directories under the extraction root. - Adds `OCIImageAdmissionWrapper` that only extracts valid OCI image layout paths. - Use `OCIImageAdmissionWrapper` for `cctl image load` and print rejected paths. * PR feedback. * Adds public init() for TrustedAdmissionMapper. * Replace AdmissionMapper with more secure extraction. - Adds FileDescriptor.mkdirSecure() to prevent root escapes on member pathnames, and to prevent symlink traversal. - Adds FileDescriptor.unlinkRecursive() to facilitate overwrites when there are multiple archive entries with the same member path. - Adds FileDescriptor.validateSymlinkTargetInRoot() to validate that extracted symlink targets do not escape the root. - Rewrite ArchiveReader.extractContents() to use secure path functions. * Remove unneeded symlink check, rename files. * Simplify the lexical normalizer workaround. * Reject member paths containing parent traversal components. * Remove unused lexical normalization workaround. * Fix leaking fds, extract absolute members as relative.
340 lines
14 KiB
Swift
340 lines
14 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 CArchive
|
|
import ContainerizationOS
|
|
import Foundation
|
|
import SystemPackage
|
|
|
|
/// A protocol for reading data in chunks, compatible with both `InputStream` and zero-allocation archive readers.
|
|
public protocol ReadableStream {
|
|
/// Reads up to `maxLength` bytes into the provided buffer.
|
|
/// Returns the number of bytes actually read, 0 for EOF, or -1 for error.
|
|
func read(_ buffer: UnsafeMutablePointer<UInt8>, maxLength: Int) -> Int
|
|
}
|
|
|
|
extension InputStream: ReadableStream {}
|
|
|
|
/// Small wrapper type to read data from an archive entry.
|
|
public struct ArchiveEntryReader: ReadableStream {
|
|
private weak var reader: ArchiveReader?
|
|
|
|
init(reader: ArchiveReader) {
|
|
self.reader = reader
|
|
}
|
|
|
|
/// Reads up to `maxLength` bytes into the provided buffer.
|
|
/// Returns the number of bytes actually read, 0 for EOF, or -1 for error.
|
|
public func read(_ buffer: UnsafeMutablePointer<UInt8>, maxLength: Int) -> Int {
|
|
guard let archive = reader?.underlying else { return -1 }
|
|
let bytesRead = archive_read_data(archive, buffer, maxLength)
|
|
return bytesRead < 0 ? -1 : bytesRead
|
|
}
|
|
}
|
|
|
|
/// A class responsible for reading entries from an archive file.
|
|
public final class ArchiveReader {
|
|
private static let blockSize = 65536
|
|
|
|
/// A pointer to the underlying `archive` C structure.
|
|
var underlying: OpaquePointer?
|
|
/// The file handle associated with the archive file being read.
|
|
let fileHandle: FileHandle?
|
|
|
|
/// Initializes an `ArchiveReader` to read from a specified file URL with an explicit `Format` and `Filter`.
|
|
/// Note: This method must be used when it is known that the archive at the specified URL follows the specified
|
|
/// `Format` and `Filter`.
|
|
public convenience init(format: Format, filter: Filter, file: URL) throws {
|
|
let fileHandle = try FileHandle(forReadingFrom: file)
|
|
try self.init(format: format, filter: filter, fileHandle: fileHandle)
|
|
}
|
|
|
|
/// Initializes an `ArchiveReader` to read from the provided file descriptor with an explicit `Format` and `Filter`.
|
|
/// Note: This method must be used when it is known that the archive pointed to by the file descriptor follows the specified
|
|
/// `Format` and `Filter`.
|
|
public init(format: Format, filter: Filter, fileHandle: FileHandle) throws {
|
|
self.underlying = archive_read_new()
|
|
self.fileHandle = fileHandle
|
|
|
|
try archive_read_set_format(underlying, format.code)
|
|
.checkOk(elseThrow: .unableToSetFormat(format.code, format))
|
|
try archive_read_append_filter(underlying, filter.code)
|
|
.checkOk(elseThrow: .unableToAddFilter(filter.code, filter))
|
|
|
|
let fd = fileHandle.fileDescriptor
|
|
try archive_read_open_fd(underlying, fd, 4096)
|
|
.checkOk(elseThrow: { .unableToOpenArchive($0) })
|
|
}
|
|
|
|
/// Initialize the `ArchiveReader` to read from a specified file URL
|
|
/// by trying to auto determine the archives `Format` and `Filter`.
|
|
public init(file: URL) throws {
|
|
self.underlying = archive_read_new()
|
|
let fileHandle = try FileHandle(forReadingFrom: file)
|
|
self.fileHandle = fileHandle
|
|
try archive_read_support_filter_all(underlying)
|
|
.checkOk(elseThrow: .failedToDetectFilter)
|
|
try archive_read_support_format_all(underlying)
|
|
.checkOk(elseThrow: .failedToDetectFormat)
|
|
let fd = fileHandle.fileDescriptor
|
|
try archive_read_open_fd(underlying, fd, 4096)
|
|
.checkOk(elseThrow: { .unableToOpenArchive($0) })
|
|
}
|
|
|
|
deinit {
|
|
archive_read_free(underlying)
|
|
try? fileHandle?.close()
|
|
}
|
|
}
|
|
|
|
extension CInt {
|
|
fileprivate func checkOk(elseThrow error: @autoclosure () -> ArchiveError) throws {
|
|
guard self == ARCHIVE_OK else { throw error() }
|
|
}
|
|
fileprivate func checkOk(elseThrow error: (CInt) -> ArchiveError) throws {
|
|
guard self == ARCHIVE_OK else { throw error(self) }
|
|
}
|
|
|
|
}
|
|
|
|
extension ArchiveReader: Sequence {
|
|
public func makeIterator() -> Iterator {
|
|
Iterator(reader: self)
|
|
}
|
|
|
|
public struct Iterator: IteratorProtocol {
|
|
var reader: ArchiveReader
|
|
|
|
public mutating func next() -> (WriteEntry, Data)? {
|
|
let entry = WriteEntry()
|
|
let result = archive_read_next_header2(reader.underlying, entry.underlying)
|
|
if result == ARCHIVE_EOF {
|
|
return nil
|
|
}
|
|
let data = reader.readDataForEntry(entry)
|
|
return (entry, data)
|
|
}
|
|
}
|
|
|
|
/// Returns an iterator that yields archive entries.
|
|
public func makeStreamingIterator() -> StreamingIterator {
|
|
StreamingIterator(reader: self)
|
|
}
|
|
|
|
public struct StreamingIterator: Sequence, IteratorProtocol {
|
|
var reader: ArchiveReader
|
|
|
|
public func makeIterator() -> StreamingIterator {
|
|
self
|
|
}
|
|
|
|
public mutating func next() -> (WriteEntry, ArchiveEntryReader)? {
|
|
let entry = WriteEntry()
|
|
let result = archive_read_next_header2(reader.underlying, entry.underlying)
|
|
if result == ARCHIVE_EOF {
|
|
return nil
|
|
}
|
|
let streamReader = ArchiveEntryReader(reader: reader)
|
|
return (entry, streamReader)
|
|
}
|
|
}
|
|
|
|
internal func readDataForEntry(_ entry: WriteEntry) -> Data {
|
|
let bufferSize = Int(Swift.min(entry.size ?? 4096, 4096))
|
|
var entry = Data()
|
|
var part = Data(count: bufferSize)
|
|
while true {
|
|
let c = part.withUnsafeMutableBytes { buffer in
|
|
guard let baseAddress = buffer.baseAddress else {
|
|
return 0
|
|
}
|
|
return archive_read_data(self.underlying, baseAddress, buffer.count)
|
|
}
|
|
guard c > 0 else { break }
|
|
part.count = c
|
|
entry.append(part)
|
|
}
|
|
return entry
|
|
}
|
|
}
|
|
|
|
extension ArchiveReader {
|
|
public convenience init(name: String, bundle: Data, tempDirectoryBaseName: String? = nil) throws {
|
|
let baseName = tempDirectoryBaseName ?? "Unarchiver"
|
|
let url = createTemporaryDirectory(baseName: baseName)!.appendingPathComponent(name)
|
|
try bundle.write(to: url, options: .atomic)
|
|
try self.init(format: .zip, filter: .none, file: url)
|
|
}
|
|
|
|
/// Extracts the contents of an archive to the provided directory.
|
|
/// Rejects member paths that escape the root directory or traverse
|
|
/// symbolic links, and uses a "last entry wins" replacement policy
|
|
/// for an existing file at a path to be extracted.
|
|
public func extractContents(to directory: URL) throws -> [String] {
|
|
// Create the root directory with standard permissions
|
|
// and create a FileDescriptor for secure path traveral.
|
|
let fm = FileManager.default
|
|
let rootFilePath = FilePath(directory.path)
|
|
try fm.createDirectory(atPath: directory.path, withIntermediateDirectories: true)
|
|
let rootFileDescriptor = try FileDescriptor.open(rootFilePath, .readOnly)
|
|
defer { try? rootFileDescriptor.close() }
|
|
|
|
// Iterate and extract archive entries, collecting rejected paths.
|
|
var foundEntry = false
|
|
var rejectedPaths = [String]()
|
|
for (entry, dataReader) in self.makeStreamingIterator() {
|
|
guard let memberPath = (entry.path.map { FilePath($0) }) else {
|
|
continue
|
|
}
|
|
foundEntry = true
|
|
|
|
// Try to extract the entry, catching path validation errors
|
|
let extracted = try extractEntry(
|
|
entry: entry,
|
|
dataReader: dataReader,
|
|
memberPath: memberPath,
|
|
rootFileDescriptor: rootFileDescriptor
|
|
)
|
|
|
|
if !extracted {
|
|
rejectedPaths.append(memberPath.string)
|
|
}
|
|
}
|
|
guard foundEntry else {
|
|
throw ArchiveError.failedToExtractArchive("no entries found in archive")
|
|
}
|
|
|
|
return rejectedPaths
|
|
}
|
|
|
|
/// This method extracts a given file from the archive.
|
|
/// This operation modifies the underlying file descriptor's position within the archive,
|
|
/// meaning subsequent reads will start from a new location.
|
|
/// To reset the underlying file descriptor to the beginning of the archive, close and
|
|
/// reopen the archive.
|
|
public func extractFile(path: String) throws -> (WriteEntry, Data) {
|
|
let entry = WriteEntry()
|
|
while archive_read_next_header2(self.underlying, entry.underlying) != ARCHIVE_EOF {
|
|
guard let entryPath = entry.path else { continue }
|
|
let trimCharSet = CharacterSet(charactersIn: "./")
|
|
let trimmedEntry = entryPath.trimmingCharacters(in: trimCharSet)
|
|
let trimmedRequired = path.trimmingCharacters(in: trimCharSet)
|
|
guard trimmedEntry == trimmedRequired else { continue }
|
|
let data = readDataForEntry(entry)
|
|
return (entry, data)
|
|
}
|
|
throw ArchiveError.failedToExtractArchive(" \(path) not found in archive")
|
|
}
|
|
|
|
/// Extracts a single archive entry.
|
|
/// Returns false if the entry was rejected due to path validation errors.
|
|
/// Throws on system errors.
|
|
private func extractEntry(
|
|
entry: WriteEntry,
|
|
dataReader: ArchiveEntryReader,
|
|
memberPath: FilePath,
|
|
rootFileDescriptor: FileDescriptor
|
|
) throws -> Bool {
|
|
guard let lastComponent = memberPath.lastComponent else {
|
|
return false
|
|
}
|
|
let relativePath = memberPath.removingLastComponent()
|
|
let type = entry.fileType
|
|
|
|
do {
|
|
switch type {
|
|
case .regular:
|
|
try rootFileDescriptor.mkdirSecure(relativePath, makeIntermediates: true) { fd in
|
|
// Remove existing entry if present (mimics containerd's "last entry wins" behavior)
|
|
try? fd.unlinkRecursiveSecure(filename: lastComponent)
|
|
|
|
// Open file for writing using openat with O_NOFOLLOW to prevent TOC-TOU attacks
|
|
let fileMode = entry.permissions & 0o777 // Mask to permission bits only
|
|
let fileFd = openat(fd.rawValue, lastComponent.string, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, fileMode)
|
|
guard fileFd >= 0 else {
|
|
throw ArchiveError.failedToExtractArchive("failed to create file: \(memberPath)")
|
|
}
|
|
defer { close(fileFd) }
|
|
|
|
try Self.copyDataReaderToFd(dataReader: dataReader, fileFd: fileFd, memberPath: memberPath)
|
|
setFileAttributes(fd: fileFd, entry: entry)
|
|
}
|
|
case .directory:
|
|
try rootFileDescriptor.mkdirSecure(memberPath, makeIntermediates: true) { fd in
|
|
setFileAttributes(fd: fd.rawValue, entry: entry)
|
|
}
|
|
case .symbolicLink:
|
|
guard let targetPath = (entry.symlinkTarget.map { FilePath($0) }) else {
|
|
return false
|
|
}
|
|
var symlinkCreated = false
|
|
try rootFileDescriptor.mkdirSecure(relativePath, makeIntermediates: true) { fd in
|
|
// Remove existing entry if present (mimics containerd's "last entry wins" behavior)
|
|
try? fd.unlinkRecursiveSecure(filename: lastComponent)
|
|
|
|
guard symlinkat(targetPath.string, fd.rawValue, lastComponent.string) == 0 else {
|
|
throw ArchiveError.failedToExtractArchive("failed to create symlink: \(targetPath) <- \(memberPath)")
|
|
}
|
|
symlinkCreated = true
|
|
}
|
|
return symlinkCreated
|
|
default:
|
|
return false
|
|
}
|
|
|
|
return true
|
|
} catch let error as SecurePathError {
|
|
// Just reject path validation errors, don't fail the extraction
|
|
switch error {
|
|
case .systemError:
|
|
// Fail for system errors
|
|
throw error
|
|
case .invalidRelativePath, .invalidPathComponent, .cannotFollowSymlink:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setFileAttributes(fd: Int32, entry: WriteEntry) {
|
|
fchmod(fd, entry.permissions)
|
|
if let owner = entry.owner, let group = entry.group {
|
|
fchown(fd, owner, group)
|
|
}
|
|
}
|
|
|
|
private static func copyDataReaderToFd(dataReader: ArchiveEntryReader, fileFd: Int32, memberPath: FilePath) throws {
|
|
var buffer = [UInt8](repeating: 0, count: ArchiveReader.blockSize)
|
|
while true {
|
|
let bytesRead = buffer.withUnsafeMutableBufferPointer { bufferPtr in
|
|
guard let baseAddress = bufferPtr.baseAddress else { return 0 }
|
|
return dataReader.read(baseAddress, maxLength: bufferPtr.count)
|
|
}
|
|
|
|
if bytesRead < 0 {
|
|
throw ArchiveError.failedToExtractArchive("failed to read data for: \(memberPath)")
|
|
}
|
|
if bytesRead == 0 {
|
|
break // EOF
|
|
}
|
|
|
|
let bytesWritten = write(fileFd, buffer, bytesRead)
|
|
guard bytesWritten == bytesRead else {
|
|
throw ArchiveError.failedToExtractArchive("failed to write data for: \(memberPath)")
|
|
}
|
|
}
|
|
}
|
|
}
|