fix(macos): block canvas symlink escapes

This commit is contained in:
Peter Steinberger
2026-03-16 23:56:32 -07:00
parent 42c8c3c983
commit 0d776c87c3
2 changed files with 40 additions and 8 deletions

View File

@@ -81,22 +81,23 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return self.html("Not Found", title: "Canvas: 404")
}
// Directory traversal guard: served files must live under the session root.
let standardizedRoot = sessionRoot.standardizedFileURL
let standardizedFile = fileURL.standardizedFileURL
guard standardizedFile.path.hasPrefix(standardizedRoot.path) else {
// Resolve symlinks before enforcing the session-root boundary so links inside
// the canvas tree cannot escape to arbitrary host files.
let resolvedRoot = sessionRoot.resolvingSymlinksInPath().standardizedFileURL
let resolvedFile = fileURL.resolvingSymlinksInPath().standardizedFileURL
guard self.isFileURL(resolvedFile, withinDirectory: resolvedRoot) else {
return self.html("Forbidden", title: "Canvas: 403")
}
do {
let data = try Data(contentsOf: standardizedFile)
let mime = CanvasScheme.mimeType(forExtension: standardizedFile.pathExtension)
let servedPath = standardizedFile.path
let data = try Data(contentsOf: resolvedFile)
let mime = CanvasScheme.mimeType(forExtension: resolvedFile.pathExtension)
let servedPath = resolvedFile.path
canvasLogger.debug(
"served \(session, privacy: .public)/\(path, privacy: .public) -> \(servedPath, privacy: .public)")
return CanvasResponse(mime: mime, data: data)
} catch {
let failedPath = standardizedFile.path
let failedPath = resolvedFile.path
let errorText = error.localizedDescription
canvasLogger
.error(
@@ -145,6 +146,11 @@ final class CanvasSchemeHandler: NSObject, WKURLSchemeHandler {
return nil
}
private func isFileURL(_ fileURL: URL, withinDirectory rootURL: URL) -> Bool {
let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/"
return fileURL.path == rootURL.path || fileURL.path.hasPrefix(rootPath)
}
private func html(_ body: String, title: String = "Canvas") -> CanvasResponse {
let html = """
<!doctype html>

View File

@@ -216,6 +216,32 @@ struct LowCoverageHelperTests {
#expect(handler._testTextEncodingName(for: "application/octet-stream") == nil)
}
@Test @MainActor func `canvas scheme handler blocks symlink escapes`() throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
let session = root.appendingPathComponent("main", isDirectory: true)
try FileManager().createDirectory(at: session, withIntermediateDirectories: true)
let outside = root.deletingLastPathComponent().appendingPathComponent("canvas-secret-\(UUID().uuidString).txt")
defer { try? FileManager().removeItem(at: outside) }
try "top-secret".write(to: outside, atomically: true, encoding: .utf8)
let symlink = session.appendingPathComponent("index.html")
try FileManager().createSymbolicLink(at: symlink, withDestinationURL: outside)
let handler = CanvasSchemeHandler(root: root)
let url = try #require(CanvasScheme.makeURL(session: "main", path: "index.html"))
let response = handler._testResponse(for: url)
let body = String(data: response.data, encoding: .utf8) ?? ""
#expect(response.mime == "text/html")
#expect(body.contains("Forbidden"))
#expect(!body.contains("top-secret"))
}
@Test @MainActor func `menu context card injector inserts and finds index`() {
let injector = MenuContextCardInjector()
let menu = NSMenu()