Protect Chrome debugging from other local OS accounts
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
|
||||
// Chrome's pipe transport belongs to one process. All hands processes on this
|
||||
// account share that owner through a socket in the account's private temp dir.
|
||||
private let chromeSocketDir = NSTemporaryDirectory() + "byre-chrome"
|
||||
private let chromeSocketPath = chromeSocketDir + "/control"
|
||||
|
||||
private func chromeAddress() throws -> sockaddr_un {
|
||||
var address = sockaddr_un()
|
||||
address.sun_family = sa_family_t(AF_UNIX)
|
||||
let bytes = Array(chromeSocketPath.utf8) + [0]
|
||||
guard bytes.count <= MemoryLayout.size(ofValue: address.sun_path) else {
|
||||
throw ToolError("Chrome's private socket path is too long")
|
||||
}
|
||||
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes) }
|
||||
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
|
||||
return address
|
||||
}
|
||||
|
||||
private func connectChromeBroker() throws -> Int32 {
|
||||
var address = try chromeAddress()
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw ToolError("create Chrome socket: errno \(errno)") }
|
||||
let result = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
guard result == 0 else {
|
||||
close(fd)
|
||||
throw ToolError("Chrome's private helper is not running")
|
||||
}
|
||||
var noSignal: Int32 = 1
|
||||
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
|
||||
return fd
|
||||
}
|
||||
|
||||
// Both the broker and Chrome are launched in their own process groups, so ending
|
||||
// one agent's hands process cannot take down the shared browser.
|
||||
func spawnChromeProcess(_ executable: String, _ args: [String], pipes: (Int32, Int32)? = nil) throws -> pid_t {
|
||||
var actions: posix_spawn_file_actions_t?
|
||||
var attrs: posix_spawnattr_t?
|
||||
posix_spawn_file_actions_init(&actions)
|
||||
posix_spawnattr_init(&attrs)
|
||||
defer {
|
||||
posix_spawn_file_actions_destroy(&actions)
|
||||
posix_spawnattr_destroy(&attrs)
|
||||
}
|
||||
posix_spawnattr_setflags(&attrs, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT))
|
||||
posix_spawnattr_setpgroup(&attrs, 0)
|
||||
for fd: Int32 in [0, 1, 2] {
|
||||
posix_spawn_file_actions_addopen(&actions, fd, "/dev/null", O_RDWR, 0)
|
||||
}
|
||||
if let (input, output) = pipes {
|
||||
posix_spawn_file_actions_adddup2(&actions, input, 3)
|
||||
posix_spawn_file_actions_adddup2(&actions, output, 4)
|
||||
}
|
||||
let argv = ([executable] + args).map { strdup($0) } + [nil]
|
||||
defer { for p in argv { free(p) } }
|
||||
var pid: pid_t = 0
|
||||
let result = argv.withUnsafeBufferPointer {
|
||||
posix_spawn(&pid, executable, &actions, &attrs, $0.baseAddress!, environ)
|
||||
}
|
||||
guard result == 0 else { throw ToolError("launch \(executable): errno \(result)") }
|
||||
return pid
|
||||
}
|
||||
|
||||
// Foundation's read(upToCount:) can wait to fill its buffer on a socket or
|
||||
// pipe. A single POSIX read returns the available bytes without waiting for EOF.
|
||||
func readChromeChunk(_ handle: FileHandle) throws -> Data? {
|
||||
var bytes = [UInt8](repeating: 0, count: 64 << 10)
|
||||
while true {
|
||||
let count = Darwin.read(handle.fileDescriptor, &bytes, bytes.count)
|
||||
if count > 0 { return Data(bytes.prefix(count)) }
|
||||
if count == 0 { return nil }
|
||||
if errno != EINTR { throw ToolError("read Chrome connection: errno \(errno)") }
|
||||
}
|
||||
}
|
||||
|
||||
private func chromeFrame(_ handle: FileHandle) throws -> Data {
|
||||
var data = Data()
|
||||
// One request per connection. Chunked reads avoid per-byte system calls.
|
||||
while true {
|
||||
guard let chunk = try readChromeChunk(handle), !chunk.isEmpty else {
|
||||
throw ToolError("Chrome's private connection closed")
|
||||
}
|
||||
if let end = chunk.firstIndex(of: 0) {
|
||||
data.append(chunk[..<end])
|
||||
guard data.count <= 256 << 20 else { throw ToolError("Chrome's reply is too large") }
|
||||
return data
|
||||
}
|
||||
data.append(chunk)
|
||||
guard data.count <= 256 << 20 else { throw ToolError("Chrome's reply is too large") }
|
||||
}
|
||||
}
|
||||
|
||||
private let chromeClientID = UUID().uuidString
|
||||
|
||||
private actor ChromeContexts {
|
||||
var clients: [String: ChromeContext] = [:]
|
||||
|
||||
func get(_ id: String, pid: pid_t) async -> ChromeContext {
|
||||
clients = clients.filter { _, context in
|
||||
kill(context.pid, 0) == 0 || errno != ESRCH
|
||||
}
|
||||
let context = clients[id] ?? ChromeContext(pid: pid)
|
||||
clients[id] = context
|
||||
// A departed helper's target sessions should stop collecting events.
|
||||
if let connection = await chromeLink.conn, let (chrome, _) = try? await connection.value {
|
||||
let detached = chrome.lock.withLock {
|
||||
let ids = chrome.sessions.filter { $0.value.context == nil }.map { $0.key }
|
||||
for id in ids { chrome.sessions.removeValue(forKey: id) }
|
||||
return ids
|
||||
}
|
||||
for id in detached {
|
||||
_ = try? await chrome.send("Target.detachFromTarget", ["sessionId": id])
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
|
||||
private let chromeContexts = ChromeContexts()
|
||||
let chromeBrokerCalls = DispatchGroup()
|
||||
|
||||
func sharedChromeTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
try await Task.detached {
|
||||
var fd = try? connectChromeBroker()
|
||||
if fd == nil {
|
||||
let pid = try spawnChromeProcess(Bundle.main.executablePath!, ["chrome-broker"])
|
||||
Thread.detachNewThread {
|
||||
var status: Int32 = 0
|
||||
while waitpid(pid, &status, 0) < 0, errno == EINTR {}
|
||||
}
|
||||
let start = Date()
|
||||
while fd == nil, Date().timeIntervalSince(start) < 15 {
|
||||
await pause(100)
|
||||
fd = try? connectChromeBroker()
|
||||
}
|
||||
}
|
||||
guard let fd else { throw ToolError("Chrome's private helper did not start within 15s") }
|
||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||
defer { try? handle.close() }
|
||||
var request: [String: Any] = ["arguments": args, "client": chromeClientID]
|
||||
request["media_dir"] = mediaDir
|
||||
var data = try JSONSerialization.data(withJSONObject: request)
|
||||
data.append(0)
|
||||
try handle.write(contentsOf: data)
|
||||
let response = try JSONSerialization.jsonObject(with: chromeFrame(handle)) as? [String: Any] ?? [:]
|
||||
if let error = response.string("error") { throw ToolError(error) }
|
||||
return (response["content"] as? [[String: Any]] ?? []).compactMap { item in
|
||||
if item.string("type") == "image", let data = Data(base64Encoded: item.string("data") ?? "") {
|
||||
return Content.png(data)
|
||||
}
|
||||
return Content.text(item.string("text") ?? "")
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
func serveChromeBroker() throws {
|
||||
let fm = FileManager.default
|
||||
if !fm.fileExists(atPath: chromeSocketDir) {
|
||||
do {
|
||||
try fm.createDirectory(atPath: chromeSocketDir, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
||||
} catch {
|
||||
if !fm.fileExists(atPath: chromeSocketDir) { throw error }
|
||||
}
|
||||
}
|
||||
let attributes = try fm.attributesOfItem(atPath: chromeSocketDir)
|
||||
guard attributes[.type] as? FileAttributeType == .typeDirectory,
|
||||
(attributes[.ownerAccountID] as? NSNumber)?.uint32Value == getuid() else {
|
||||
throw ToolError("Chrome's socket directory is not owned by this account")
|
||||
}
|
||||
try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: chromeSocketDir)
|
||||
let lock = Darwin.open(chromeSocketDir + "/lock", O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0o600)
|
||||
guard lock >= 0 else { throw ToolError("open Chrome helper lock: errno \(errno)") }
|
||||
defer { close(lock) }
|
||||
guard flock(lock, LOCK_EX | LOCK_NB) == 0 else { return }
|
||||
unlink(chromeSocketPath)
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw ToolError("create Chrome listener: errno \(errno)") }
|
||||
defer {
|
||||
close(fd)
|
||||
unlink(chromeSocketPath)
|
||||
}
|
||||
var address = try chromeAddress()
|
||||
let bound = withUnsafePointer(to: &address) {
|
||||
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
guard bound == 0, listen(fd, 16) == 0 else { throw ToolError("listen on Chrome socket: errno \(errno)") }
|
||||
while true {
|
||||
let client = accept(fd, nil, nil)
|
||||
if client < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw ToolError("accept Chrome connection: errno \(errno)")
|
||||
}
|
||||
// Check the peer too: filesystem permissions alone are not the boundary.
|
||||
var uid: uid_t = 0
|
||||
var gid: gid_t = 0
|
||||
guard getpeereid(client, &uid, &gid) == 0, uid == getuid() else {
|
||||
close(client)
|
||||
continue
|
||||
}
|
||||
var pid: pid_t = 0
|
||||
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
|
||||
guard getsockopt(client, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize) == 0 else {
|
||||
close(client)
|
||||
continue
|
||||
}
|
||||
var noSignal: Int32 = 1
|
||||
setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
|
||||
chromeBrokerCalls.enter()
|
||||
Task.detached {
|
||||
defer { chromeBrokerCalls.leave() }
|
||||
let handle = FileHandle(fileDescriptor: client, closeOnDealloc: true)
|
||||
defer { try? handle.close() }
|
||||
var response: [String: Any]
|
||||
do {
|
||||
let request = try JSONSerialization.jsonObject(with: chromeFrame(handle)) as? [String: Any] ?? [:]
|
||||
let context = await chromeContexts.get(request.string("client") ?? "", pid: pid)
|
||||
let content = try await chromeTool(request.dict("arguments") ?? [:], saveDir: request.string("media_dir"), context: context)
|
||||
response = ["content": content.map { $0.json }]
|
||||
} catch let error as ToolError {
|
||||
response = ["error": error.message]
|
||||
} catch {
|
||||
response = ["error": "\(error)"]
|
||||
}
|
||||
if var data = try? JSONSerialization.data(withJSONObject: response) {
|
||||
data.append(0)
|
||||
try? handle.write(contentsOf: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user