Skip to content

Commit a1a47f2

Browse files
committed
Archiver: use secure archive extraction
1 parent 92d12d6 commit a1a47f2

3 files changed

Lines changed: 93 additions & 136 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
name: "ContainerAPIClientTests",
195195
dependencies: [
196196
.product(name: "Containerization", package: "containerization"),
197+
.product(name: "ContainerizationArchive", package: "containerization"),
197198
"ContainerAPIClient",
198199
"ContainerPersistence",
199200
]

Sources/Services/ContainerAPIService/Client/Archiver.swift

Lines changed: 10 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -158,86 +158,14 @@ public final class Archiver: Sendable {
158158
let source = source.standardizedFileURL
159159
let destination = destination.standardizedFileURL
160160

161-
// TODO: ArchiveReader needs some enhancement to support buffered uncompression
162161
let reader = try ArchiveReader(
163162
format: .paxRestricted,
164163
filter: .gzip,
165164
file: source
166165
)
167-
168-
for (entry, data) in reader {
169-
guard let path = entry.path else {
170-
continue
171-
}
172-
let uncompressPath = destination.appendingPathComponent(path)
173-
174-
let fileManager = FileManager.default
175-
switch entry.fileType {
176-
case .blockSpecial, .characterSpecial, .socket:
177-
continue
178-
case .directory:
179-
try fileManager.createDirectory(
180-
at: uncompressPath,
181-
withIntermediateDirectories: true,
182-
attributes: [
183-
FileAttributeKey.posixPermissions: entry.permissions
184-
]
185-
)
186-
case .regular:
187-
try fileManager.createDirectory(
188-
at: uncompressPath.deletingLastPathComponent(),
189-
withIntermediateDirectories: true,
190-
attributes: [
191-
FileAttributeKey.posixPermissions: 0o755
192-
]
193-
)
194-
let success = fileManager.createFile(
195-
atPath: uncompressPath.path,
196-
contents: data,
197-
attributes: [
198-
FileAttributeKey.posixPermissions: entry.permissions
199-
]
200-
)
201-
if !success {
202-
throw POSIXError.fromErrno()
203-
}
204-
try data.write(to: uncompressPath)
205-
case .symbolicLink:
206-
guard let target = entry.symlinkTarget else {
207-
continue
208-
}
209-
try fileManager.createDirectory(
210-
at: uncompressPath.deletingLastPathComponent(),
211-
withIntermediateDirectories: true,
212-
attributes: [
213-
FileAttributeKey.posixPermissions: 0o755
214-
]
215-
)
216-
try fileManager.createSymbolicLink(atPath: uncompressPath.path, withDestinationPath: target)
217-
continue
218-
default:
219-
continue
220-
}
221-
222-
// FIXME: uid/gid for compress.
223-
try fileManager.setAttributes(
224-
[.posixPermissions: NSNumber(value: entry.permissions)],
225-
ofItemAtPath: uncompressPath.path
226-
)
227-
228-
if let creationDate = entry.creationDate {
229-
try fileManager.setAttributes(
230-
[.creationDate: creationDate],
231-
ofItemAtPath: uncompressPath.path
232-
)
233-
}
234-
235-
if let modificationDate = entry.modificationDate {
236-
try fileManager.setAttributes(
237-
[.modificationDate: modificationDate],
238-
ofItemAtPath: uncompressPath.path
239-
)
240-
}
166+
let rejectedMembers = try reader.extractContents(to: destination)
167+
if !rejectedMembers.isEmpty {
168+
throw Error.rejectedArchiveMembers(rejectedMembers)
241169
}
242170
}
243171

@@ -248,18 +176,14 @@ public final class Archiver: Sendable {
248176
writerConfiguration: ArchiveWriterConfiguration,
249177
hasher: inout SHA256
250178
) throws {
251-
let archivedPathsByHostPath = entryInfo.reduce(into: [String: [URL]]()) { result, info in
252-
result[info.pathOnHost.path, default: []].append(info.pathInArchive)
253-
}
254-
255179
let archiver = try ArchiveWriter(configuration: writerConfiguration)
256180
try archiver.open(file: destination)
257181

258182
let encoder = JSONEncoder()
259183
encoder.outputFormatting = .sortedKeys
260184

261185
for info in entryInfo {
262-
guard let entry = try Self._createEntry(entryInfo: info, archivedPathsByHostPath: archivedPathsByHostPath) else {
186+
guard let entry = try Self._createEntry(entryInfo: info) else {
263187
throw Error.failedToCreateEntry
264188
}
265189
let hashInfo = ArchiveEntryHashInfo(
@@ -324,7 +248,6 @@ public final class Archiver: Sendable {
324248

325249
private static func _createEntry(
326250
entryInfo: ArchiveEntryInfo,
327-
archivedPathsByHostPath: [String: [URL]] = [:],
328251
pathPrefix: String = ""
329252
) throws -> WriteEntry? {
330253
let entry = WriteEntry()
@@ -341,11 +264,8 @@ public final class Archiver: Sendable {
341264
case .symbolicLink:
342265
entry.fileType = .symbolicLink
343266
entry.size = 0
344-
entry.symlinkTarget = Self._rewriteArchivedAbsoluteSymlinkTarget(
345-
status.symlinkTarget ?? "",
346-
entryInfo: entryInfo,
347-
archivedPathsByHostPath: archivedPathsByHostPath
348-
)
267+
// Match Docker build-context semantics and preserve the original target verbatim.
268+
entry.symlinkTarget = status.symlinkTarget
349269
}
350270

351271
#if os(macOS)
@@ -469,51 +389,6 @@ public final class Archiver: Sendable {
469389
return trimmedPath
470390
}
471391

472-
private static func _rewriteArchivedAbsoluteSymlinkTarget(
473-
_ symlinkTarget: String,
474-
entryInfo: ArchiveEntryInfo,
475-
archivedPathsByHostPath: [String: [URL]]
476-
) -> String {
477-
guard symlinkTarget.hasPrefix("/") else {
478-
return symlinkTarget
479-
}
480-
481-
let targetPath = URL(fileURLWithPath: symlinkTarget)
482-
.standardizedFileURL
483-
.resolvingSymlinksInPath()
484-
.path
485-
guard let targetArchivePaths = archivedPathsByHostPath[targetPath], targetArchivePaths.count == 1, let targetArchivePath = targetArchivePaths.first else {
486-
return symlinkTarget
487-
}
488-
489-
let sourceDirectory = entryInfo.pathInArchive.deletingLastPathComponent().relativePath
490-
return Self._relativeArchivePath(fromDirectory: sourceDirectory, to: targetArchivePath.relativePath)
491-
}
492-
493-
private static func _relativeArchivePath(fromDirectory: String, to path: String) -> String {
494-
let fromComponents = Self._archivePathComponents(fromDirectory)
495-
let toComponents = Self._archivePathComponents(path)
496-
497-
var commonPrefixCount = 0
498-
while commonPrefixCount < fromComponents.count,
499-
commonPrefixCount < toComponents.count,
500-
fromComponents[commonPrefixCount] == toComponents[commonPrefixCount]
501-
{
502-
commonPrefixCount += 1
503-
}
504-
505-
let upwardTraversal = Array(repeating: "..", count: fromComponents.count - commonPrefixCount)
506-
let remainder = Array(toComponents.dropFirst(commonPrefixCount))
507-
let relativeComponents = upwardTraversal + remainder
508-
return relativeComponents.isEmpty ? "." : relativeComponents.joined(separator: "/")
509-
}
510-
511-
private static func _archivePathComponents(_ path: String) -> [String] {
512-
NSString(string: path).pathComponents.filter { component in
513-
component != "/" && component != "."
514-
}
515-
}
516-
517392
private static func _isSymbolicLink(_ path: URL) throws -> Bool {
518393
let resourceValues = try path.resourceValues(forKeys: [.isSymbolicLinkKey])
519394
if let isSymbolicLink = resourceValues.isSymbolicLink {
@@ -526,16 +401,19 @@ public final class Archiver: Sendable {
526401
}
527402

528403
extension Archiver {
529-
public enum Error: Swift.Error, CustomStringConvertible {
404+
public enum Error: Swift.Error, CustomStringConvertible, Equatable {
530405
case failedToCreateEntry
531406
case fileDoesNotExist(_ url: URL)
407+
case rejectedArchiveMembers([String])
532408

533409
public var description: String {
534410
switch self {
535411
case .failedToCreateEntry:
536412
return "failed to create entry"
537413
case .fileDoesNotExist(let url):
538414
return "file \(url.path) does not exist"
415+
case .rejectedArchiveMembers(let members):
416+
return "rejected archive members: \(members.joined(separator: ", "))"
539417
}
540418
}
541419
}

Tests/ContainerAPIClientTests/ArchiverTests.swift

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,55 @@
1717
import Foundation
1818
import Testing
1919

20+
import ContainerizationArchive
21+
2022
@testable import ContainerAPIClient
2123

2224
struct ArchiverTests {
25+
private enum EntryType {
26+
case regular(String)
27+
case directory
28+
case symlink(String)
29+
}
30+
31+
private func createTestArchive(
32+
name: String,
33+
entries: [(path: String, type: EntryType)],
34+
baseDirectory: URL
35+
) throws -> URL {
36+
let archiveURL = baseDirectory.appendingPathComponent("\(name).tar.gz")
37+
let archiver = try ArchiveWriter(format: .paxRestricted, filter: .gzip, file: archiveURL)
38+
39+
for entry in entries {
40+
let writeEntry = WriteEntry()
41+
writeEntry.path = entry.path
42+
writeEntry.permissions = 0o644
43+
writeEntry.owner = 1000
44+
writeEntry.group = 1000
45+
46+
switch entry.type {
47+
case .regular(let content):
48+
writeEntry.fileType = .regular
49+
let data = try #require(content.data(using: .utf8))
50+
writeEntry.size = numericCast(data.count)
51+
try archiver.writeEntry(entry: writeEntry, data: data)
52+
case .directory:
53+
writeEntry.fileType = .directory
54+
writeEntry.permissions = 0o755
55+
writeEntry.size = 0
56+
try archiver.writeEntry(entry: writeEntry, data: nil)
57+
case .symlink(let target):
58+
writeEntry.fileType = .symbolicLink
59+
writeEntry.symlinkTarget = target
60+
writeEntry.size = 0
61+
try archiver.writeEntry(entry: writeEntry, data: nil)
62+
}
63+
}
64+
65+
try archiver.finishEncoding()
66+
return archiveURL
67+
}
68+
2369
@Test
2470
func testCompressAndUncompressPreservesRelativeSymbolicLink() throws {
2571
let fileManager = FileManager.default
@@ -100,7 +146,7 @@ struct ArchiverTests {
100146
}
101147

102148
@Test
103-
func testCompressAndUncompressRewritesArchivedAbsoluteSymbolicLinkTarget() throws {
149+
func testCompressAndUncompressPreservesInternalAbsoluteSymbolicLinkTarget() throws {
104150
let fileManager = FileManager.default
105151
let tempURL = try fileManager.url(
106152
for: .itemReplacementDirectory,
@@ -135,12 +181,12 @@ struct ArchiverTests {
135181
let extractedLinkURL = destinationURL.appendingPathComponent("link.txt")
136182
let values = try extractedLinkURL.resourceValues(forKeys: [.isSymbolicLinkKey])
137183
#expect(values.isSymbolicLink == true)
138-
#expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == "target.txt")
184+
#expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == targetURL.path)
139185
#expect(try String(contentsOf: extractedLinkURL, encoding: .utf8) == "hello")
140186
}
141187

142188
@Test
143-
func testCompressAndUncompressRewritesArchivedAbsoluteSymbolicLinkTargetThroughSymlinkedAncestor() throws {
189+
func testCompressAndUncompressPreservesAbsoluteSymbolicLinkTargetThroughSymlinkedAncestor() throws {
144190
let fileManager = FileManager.default
145191
let tempURL = try fileManager.url(
146192
for: .itemReplacementDirectory,
@@ -184,7 +230,10 @@ struct ArchiverTests {
184230
let extractedLinkURL = destinationURL.appendingPathComponent("link.txt")
185231
let values = try extractedLinkURL.resourceValues(forKeys: [.isSymbolicLinkKey])
186232
#expect(values.isSymbolicLink == true)
187-
#expect(try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path) == "real/target.txt")
233+
#expect(
234+
try fileManager.destinationOfSymbolicLink(atPath: extractedLinkURL.path)
235+
== sourceURL.appendingPathComponent("alias/target.txt").path
236+
)
188237
#expect(try String(contentsOf: extractedLinkURL, encoding: .utf8) == "hello")
189238
}
190239

@@ -259,6 +308,35 @@ struct ArchiverTests {
259308
#expect(!fileManager.fileExists(atPath: destinationURL.appendingPathComponent("exclude.txt").path))
260309
}
261310

311+
@Test
312+
func testUncompressRejectsPathTraversalMembers() throws {
313+
let fileManager = FileManager.default
314+
let tempURL = try fileManager.url(
315+
for: .itemReplacementDirectory,
316+
in: .userDomainMask,
317+
appropriateFor: .temporaryDirectory,
318+
create: true
319+
)
320+
defer { try? fileManager.removeItem(at: tempURL) }
321+
322+
let archiveURL = try createTestArchive(
323+
name: "traversal",
324+
entries: [
325+
(path: "safe.txt", type: .regular("safe")),
326+
(path: "../outside.txt", type: .regular("evil")),
327+
],
328+
baseDirectory: tempURL
329+
)
330+
let destinationURL = tempURL.appendingPathComponent("destination")
331+
332+
#expect(throws: Archiver.Error.rejectedArchiveMembers(["../outside.txt"])) {
333+
try Archiver.uncompress(source: archiveURL, destination: destinationURL)
334+
}
335+
336+
#expect(fileManager.fileExists(atPath: destinationURL.appendingPathComponent("safe.txt").path))
337+
#expect(!fileManager.fileExists(atPath: tempURL.appendingPathComponent("outside.txt").path))
338+
}
339+
262340
private func archiveDigest(sourceURL: URL, destinationURL: URL) throws -> String {
263341
let digest = try Archiver.compress(source: sourceURL, destination: destinationURL) { url in
264342
let sourcePath = sourceURL.standardizedFileURL.path

0 commit comments

Comments
 (0)