mirror of
https://github.com/apple/container.git
synced 2026-09-13 03:05:42 +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.
219 lines
8.8 KiB
Swift
219 lines
8.8 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 SystemPackage
|
|
|
|
#if canImport(Darwin)
|
|
import Darwin
|
|
let os_dup = Darwin.dup
|
|
#elseif canImport(Musl)
|
|
import CSystem
|
|
import Musl
|
|
let os_dup = Musl.dup
|
|
#elseif canImport(Glibc)
|
|
import Glibc
|
|
let os_dup = Glibc.dup
|
|
#endif
|
|
|
|
extension FileDescriptor {
|
|
/// Creates a directory relative to the FileDescriptor, rejecting
|
|
/// paths that traverse symlinks.
|
|
///
|
|
/// - Parameters:
|
|
/// - relativePath: The path to the directory to create, relative to the FileDescriptor
|
|
/// - permissions: The permissions to give the directory (default is 0o755)
|
|
/// - makeIntermediates: Create or replace intermediate components as needed
|
|
/// - completion: A function that operates on the new directory
|
|
/// - Throws: `SecurePathError` if path validation or system errors occur
|
|
public func mkdirSecure(
|
|
_ relativePath: FilePath,
|
|
permissions: FilePermissions? = nil,
|
|
makeIntermediates: Bool = false,
|
|
completion: (FileDescriptor) throws -> Void = { _ in }
|
|
) throws {
|
|
try Self.validateRelativePath(relativePath)
|
|
try mkdirSecure(
|
|
relativePath.components,
|
|
permissions: permissions,
|
|
makeIntermediates: makeIntermediates,
|
|
completion: completion
|
|
)
|
|
}
|
|
|
|
/// Recursively removes a direct child of a directory FileDescriptor.
|
|
///
|
|
/// - Parameters:
|
|
/// - filename: The name of the child file
|
|
/// - Throws: `SecurePathError` if system errors occur
|
|
public func unlinkRecursiveSecure(filename: FilePath.Component) throws {
|
|
guard filename.string != "." && filename.string != ".." else {
|
|
return
|
|
}
|
|
|
|
// Try to remove as a file, and continue if the remove fails.
|
|
guard unlinkat(self.rawValue, filename.string, 0) != 0 else {
|
|
return
|
|
}
|
|
|
|
// Return if the file already doesn't exist.
|
|
guard errno != ENOENT else {
|
|
return
|
|
}
|
|
|
|
// If the file is not a directory, then throw a real error.
|
|
guard errno == EPERM || errno == EISDIR else {
|
|
throw SecurePathError.systemError("file removal during secure unlink", errno)
|
|
}
|
|
|
|
// Get the fd for the next path component.
|
|
let componentFd = openat(self.rawValue, filename.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
|
|
guard componentFd >= 0 else {
|
|
throw SecurePathError.systemError("directory open during secure unlink", errno)
|
|
}
|
|
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
|
|
defer { try? componentFileDescriptor.close() }
|
|
|
|
// Open the directory stream using a duplicate fd that closedir() will close.
|
|
let ownedFd = os_dup(componentFd)
|
|
guard let dir = fdopendir(ownedFd) else {
|
|
throw SecurePathError.systemError("directory opendir during secure unlink", errno)
|
|
}
|
|
defer { closedir(dir) }
|
|
|
|
// Recurse into each directory entry.
|
|
while let entry = readdir(dir) {
|
|
let childComponent = withUnsafePointer(to: entry.pointee.d_name) {
|
|
$0.withMemoryRebound(to: UInt8.self, capacity: Int(NAME_MAX) + 1) {
|
|
let name = String(decodingCString: $0, as: UTF8.self)
|
|
return FilePath.Component(name)
|
|
}
|
|
}
|
|
guard let childComponent else {
|
|
throw SecurePathError.systemError("directory entry processing during secure unlink", errno)
|
|
}
|
|
try componentFileDescriptor.unlinkRecursiveSecure(filename: childComponent)
|
|
}
|
|
|
|
// The current directory is empty now, remove it.
|
|
if unlinkat(self.rawValue, filename.string, AT_REMOVEDIR) != 0 {
|
|
throw SecurePathError.systemError("directory removal during secure unlink", errno)
|
|
}
|
|
}
|
|
|
|
private func mkdirSecure(
|
|
_ relativeComponents: FilePath.ComponentView,
|
|
permissions: FilePermissions? = nil,
|
|
makeIntermediates: Bool,
|
|
completion: (FileDescriptor) throws -> Void
|
|
) throws {
|
|
// If the relative path is empty, call completion with self (the parent directory)
|
|
guard let currentComponent = relativeComponents.first else {
|
|
try completion(self)
|
|
return
|
|
}
|
|
let childComponents = FilePath.ComponentView(relativeComponents.dropFirst())
|
|
|
|
// Create or replace the directory as needed.
|
|
let parentFd = self.rawValue
|
|
var componentFd = openat(parentFd, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
|
|
if componentFd < 0 {
|
|
// If the non-directory component should be replaced with a directory, remove the component.
|
|
guard makeIntermediates || childComponents.isEmpty else {
|
|
throw SecurePathError.invalidPathComponent
|
|
}
|
|
if errno != ENOENT {
|
|
try self.unlinkRecursiveSecure(filename: currentComponent)
|
|
}
|
|
|
|
// Create and open an empty directory.
|
|
guard mkdirat(parentFd, currentComponent.string, permissions?.rawValue ?? 0o755) == 0 else {
|
|
throw SecurePathError.systemError("directory creation during secure mkdir", errno)
|
|
}
|
|
|
|
componentFd = openat(parentFd, currentComponent.string, O_NOFOLLOW | O_RDONLY | O_DIRECTORY)
|
|
guard componentFd >= 0 else {
|
|
throw SecurePathError.systemError("directory open during secure mkdir", errno)
|
|
}
|
|
}
|
|
|
|
let componentFileDescriptor = FileDescriptor(rawValue: componentFd)
|
|
defer { try? componentFileDescriptor.close() }
|
|
|
|
// Call the completion closure for the last component.
|
|
guard !childComponents.isEmpty else {
|
|
try completion(componentFileDescriptor)
|
|
return
|
|
}
|
|
|
|
// Create the directory for the remaining components.
|
|
try componentFileDescriptor.mkdirSecure(childComponents, permissions: permissions, makeIntermediates: makeIntermediates, completion: completion)
|
|
}
|
|
|
|
private static func validateRelativePath(_ path: FilePath) throws {
|
|
// Allow absolute paths; only the components will be used during traversal.
|
|
guard !(path.components.contains { $0 == ".." }) else {
|
|
throw SecurePathError.invalidRelativePath
|
|
}
|
|
}
|
|
|
|
#if canImport(Darwin)
|
|
public func getCanonicalPath() throws -> FilePath {
|
|
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
|
guard fcntl(self.rawValue, F_GETPATH, &buffer) != -1 else {
|
|
throw Errno(rawValue: errno)
|
|
}
|
|
|
|
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
|
let pathname = String(decoding: bytes, as: UTF8.self)
|
|
return FilePath(pathname)
|
|
}
|
|
#elseif canImport(Glibc) || canImport(Musl)
|
|
public func getCanonicalPath() throws -> FilePath {
|
|
let fdPath = "/proc/self/fd/\(self.rawValue)"
|
|
// Use readlink to resolve the symlink
|
|
var buffer = [CChar](repeating: 0, count: 4096)
|
|
let len = readlink(fdPath, &buffer, buffer.count - 1)
|
|
guard len > 0 else {
|
|
throw SecurePathError.systemError("readlink", errno)
|
|
}
|
|
// Convert to bytes without null termination
|
|
let bytes = buffer.prefix(len).map { UInt8(bitPattern: $0) }
|
|
let pathname = String(decoding: bytes, as: UTF8.self)
|
|
return FilePath(pathname)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
public enum SecurePathError: Error, CustomStringConvertible, Equatable {
|
|
case invalidRelativePath
|
|
case invalidPathComponent
|
|
case cannotFollowSymlink
|
|
case systemError(String, Int32)
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .invalidRelativePath:
|
|
return "invalid relative path supplied to secure path operation"
|
|
case .invalidPathComponent:
|
|
return "an intermediate path component is missing or is not a directory"
|
|
case .cannotFollowSymlink:
|
|
return "cannot follow a symlink an a secure path operation"
|
|
case .systemError(let operation, let err):
|
|
return "\(operation) returned error: \(err)"
|
|
}
|
|
}
|
|
}
|