Protect Chrome debugging from other local OS accounts
This commit is contained in:
@@ -2,7 +2,6 @@ import AppKit
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
let chromePort = 9222
|
||||
let chromeProfile = FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support/byre/chrome").path
|
||||
|
||||
final class Req {
|
||||
@@ -63,7 +62,16 @@ final class Chain {
|
||||
}
|
||||
}
|
||||
|
||||
final class ChromeContext {
|
||||
let pid: pid_t
|
||||
var tabs: [String: Task<TabState, Error>] = [:]
|
||||
var current: String?
|
||||
|
||||
init(pid: pid_t) { self.pid = pid }
|
||||
}
|
||||
|
||||
final class TabState {
|
||||
weak var context: ChromeContext?
|
||||
let session: String
|
||||
let target: String
|
||||
var seq = 0
|
||||
@@ -99,50 +107,59 @@ final class TabState {
|
||||
}
|
||||
|
||||
final class Chrome: @unchecked Sendable {
|
||||
let ws: URLSessionWebSocketTask
|
||||
let input: FileHandle
|
||||
let output: FileHandle
|
||||
let writes = DispatchQueue(label: "run.fc.byre.chrome-pipe")
|
||||
let lock = NSLock()
|
||||
var next = 0
|
||||
var pending: [Int: CheckedContinuation<[String: Any], Error>] = [:]
|
||||
var tabs: [String: Task<TabState, Error>] = [:]
|
||||
var sessions: [String: TabState] = [:]
|
||||
var current: String?
|
||||
var closed = false
|
||||
|
||||
init(_ url: URL) {
|
||||
ws = URLSession.shared.webSocketTask(with: url)
|
||||
ws.maximumMessageSize = 1 << 28
|
||||
ws.resume()
|
||||
receive()
|
||||
}
|
||||
|
||||
func receive() {
|
||||
ws.receive { [self] result in
|
||||
switch result {
|
||||
case .failure(let e):
|
||||
fail(e)
|
||||
case .success(let msg):
|
||||
switch msg {
|
||||
case .string(let s):
|
||||
dispatch(Data(s.utf8))
|
||||
case .data(let d):
|
||||
dispatch(d)
|
||||
@unknown default:
|
||||
break
|
||||
init(input: FileHandle, output: FileHandle, pid: pid_t) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
Thread.detachNewThread { [self] in
|
||||
var buffer = Data()
|
||||
do {
|
||||
while let chunk = try readChromeChunk(output), !chunk.isEmpty {
|
||||
buffer.append(chunk)
|
||||
while let end = buffer.firstIndex(of: 0) {
|
||||
dispatch(Data(buffer[..<end]))
|
||||
buffer.removeSubrange(...end)
|
||||
}
|
||||
guard buffer.count <= 256 << 20 else {
|
||||
throw ToolError("Chrome's message is too large")
|
||||
}
|
||||
}
|
||||
receive()
|
||||
fail(ToolError("Chrome closed its debugging pipe; if byre's Chrome was already open before this update, quit that browser once and retry"))
|
||||
} catch {
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
Thread.detachNewThread {
|
||||
var status: Int32 = 0
|
||||
while waitpid(pid, &status, 0) < 0, errno == EINTR {}
|
||||
// Finish replies already in flight, then retire the browser owner.
|
||||
// The next call launches the current hands binary, including updates.
|
||||
chromeBrokerCalls.notify(queue: .global()) { exit(0) }
|
||||
}
|
||||
}
|
||||
|
||||
func fail(_ e: Error) {
|
||||
let waiting = lock.withLock {
|
||||
if closed { return [Int: CheckedContinuation<[String: Any], Error>]() }
|
||||
closed = true
|
||||
let p = pending
|
||||
pending = [:]
|
||||
return p
|
||||
}
|
||||
let message = (e as? ToolError)?.message ?? e.localizedDescription
|
||||
for c in waiting.values {
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome: \(e.localizedDescription)"))
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome: \(message)"))
|
||||
}
|
||||
writes.async { [self] in
|
||||
try? input.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +192,14 @@ final class Chrome: @unchecked Sendable {
|
||||
c.resume(throwing: ToolError("lost the connection to Chrome"))
|
||||
return
|
||||
}
|
||||
ws.send(.string(String(decoding: data, as: UTF8.self))) { [self] err in
|
||||
if let err {
|
||||
resolve(id, .failure(err))
|
||||
writes.async { [self] in
|
||||
guard !lock.withLock({ closed }) else { return }
|
||||
do {
|
||||
var frame = data
|
||||
frame.append(0)
|
||||
try input.write(contentsOf: frame)
|
||||
} catch {
|
||||
fail(error)
|
||||
}
|
||||
}
|
||||
Task {
|
||||
@@ -203,7 +225,7 @@ final class Chrome: @unchecked Sendable {
|
||||
let p = m.dict("params") ?? [:]
|
||||
lock.withLock {
|
||||
if method == "Target.detachedFromTarget", let s = p.string("sessionId"), let t = sessions.removeValue(forKey: s) {
|
||||
tabs.removeValue(forKey: t.target)
|
||||
t.context?.tabs.removeValue(forKey: t.target)
|
||||
return
|
||||
}
|
||||
guard let s = m.string("sessionId"), let t = sessions[s] else {
|
||||
@@ -298,42 +320,43 @@ final class Chrome: @unchecked Sendable {
|
||||
return (r["targetInfos"] as? [[String: Any]] ?? []).filter { $0.string("type") == "page" }
|
||||
}
|
||||
|
||||
func tab(_ id: String?) async throws -> (String, TabState) {
|
||||
func tab(_ id: String?, context: ChromeContext) async throws -> (String, TabState) {
|
||||
var target = id
|
||||
if target == nil {
|
||||
let open = try await pages().compactMap { $0.string("targetId") }
|
||||
target = lock.withLock { current }.flatMap { open.contains($0) ? $0 : nil } ?? open.first
|
||||
target = lock.withLock { context.current }.flatMap { open.contains($0) ? $0 : nil } ?? open.first
|
||||
}
|
||||
guard let target else {
|
||||
throw ToolError("Chrome has no tabs; navigate with new_tab to open one")
|
||||
}
|
||||
let task = lock.withLock {
|
||||
current = target
|
||||
if let a = tabs[target] {
|
||||
context.current = target
|
||||
if let a = context.tabs[target] {
|
||||
return a
|
||||
}
|
||||
let a = Task { try await attach(target) }
|
||||
tabs[target] = a
|
||||
let a = Task { try await attach(target, context: context) }
|
||||
context.tabs[target] = a
|
||||
return a
|
||||
}
|
||||
do {
|
||||
return (target, try await task.value)
|
||||
} catch {
|
||||
lock.withLock {
|
||||
if tabs[target] == task {
|
||||
tabs.removeValue(forKey: target)
|
||||
if context.tabs[target] == task {
|
||||
context.tabs.removeValue(forKey: target)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ target: String) async throws -> TabState {
|
||||
func attach(_ target: String, context: ChromeContext) async throws -> TabState {
|
||||
let r = try await send("Target.attachToTarget", ["targetId": target, "flatten": true])
|
||||
guard let s = r.string("sessionId") else {
|
||||
throw ToolError("Chrome attached to \(target) without a session")
|
||||
}
|
||||
let t = TabState(session: s, target: target)
|
||||
t.context = context
|
||||
lock.withLock { sessions[s] = t }
|
||||
for m in ["Page.enable", "Network.enable", "Runtime.enable", "Log.enable"] {
|
||||
_ = try await send(m, session: s)
|
||||
@@ -367,49 +390,54 @@ func consoleLine(_ level: String, _ text: String, _ url: String?, _ line: Int?)
|
||||
return "\(level) \(text) (\(url):\((line ?? 0) + 1))"
|
||||
}
|
||||
|
||||
func chromeVersion() async -> [String: Any]? {
|
||||
var req = URLRequest(url: URL(string: "http://127.0.0.1:\(chromePort)/json/version")!)
|
||||
req.timeoutInterval = 2
|
||||
guard let (d, _) = try? await URLSession.shared.data(for: req) else {
|
||||
return nil
|
||||
}
|
||||
return try? JSONSerialization.jsonObject(with: d) as? [String: Any]
|
||||
}
|
||||
|
||||
func openChrome() async throws -> (Chrome, String) {
|
||||
var note = ""
|
||||
var info = await chromeVersion()
|
||||
if info == nil {
|
||||
let lock = try await lockInput()
|
||||
defer {
|
||||
close(lock)
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: "/usr/bin/open")
|
||||
p.arguments = ["-na", "Google Chrome", "--args", "--user-data-dir=\(chromeProfile)", "--remote-debugging-port=\(chromePort)", "--no-first-run", "--no-default-browser-check"]
|
||||
try p.run()
|
||||
p.waitUntilExit()
|
||||
if p.terminationStatus != 0 {
|
||||
throw ToolError("could not open Google Chrome (open exited \(p.terminationStatus)); is it installed on this Mac?")
|
||||
}
|
||||
let start = Date()
|
||||
while info == nil, Date().timeIntervalSince(start) < 15 {
|
||||
await pause(200)
|
||||
info = await chromeVersion()
|
||||
}
|
||||
note = "started Chrome with byre's profile\n"
|
||||
let lock = try await lockInput()
|
||||
defer { close(lock) }
|
||||
guard let app = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.google.Chrome"),
|
||||
let executable = Bundle(url: app)?.executablePath else {
|
||||
throw ToolError("Google Chrome is not installed on this Mac")
|
||||
}
|
||||
guard let ws = info?.string("webSocketDebuggerUrl").flatMap(URL.init(string:)) else {
|
||||
throw ToolError("Chrome's debugging port \(chromePort) didn't answer within 15s")
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(atPath: chromeProfile, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: chromeProfile)
|
||||
let commands = Pipe()
|
||||
let replies = Pipe()
|
||||
guard fcntl(commands.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) == 0 else {
|
||||
throw ToolError("configure Chrome command pipe: errno \(errno)")
|
||||
}
|
||||
return (Chrome(ws), note)
|
||||
// Keep sources out of Chrome's fd 3/4 destinations during posix_spawn.
|
||||
let readFD = fcntl(commands.fileHandleForReading.fileDescriptor, F_DUPFD_CLOEXEC, 10)
|
||||
guard readFD >= 0 else { throw ToolError("create Chrome command pipe: errno \(errno)") }
|
||||
defer { close(readFD) }
|
||||
let writeFD = fcntl(replies.fileHandleForWriting.fileDescriptor, F_DUPFD_CLOEXEC, 10)
|
||||
guard writeFD >= 0 else { throw ToolError("create Chrome reply pipe: errno \(errno)") }
|
||||
defer { close(writeFD) }
|
||||
let pid = try spawnChromeProcess(executable, ["--user-data-dir=\(chromeProfile)", "--remote-debugging-pipe", "--no-first-run", "--no-default-browser-check"], pipes: (readFD, writeFD))
|
||||
try commands.fileHandleForReading.close()
|
||||
try replies.fileHandleForWriting.close()
|
||||
let chrome = Chrome(input: commands.fileHandleForWriting, output: replies.fileHandleForReading, pid: pid)
|
||||
// Prove the private connection works; never discover or fall back to a TCP debugger.
|
||||
do {
|
||||
_ = try await chrome.send("Browser.getVersion", timeout: 15)
|
||||
} catch {
|
||||
// Only the newly launched process group belongs to this attempt. An
|
||||
// existing browser using this profile must be quit by the human.
|
||||
kill(-pid, SIGTERM)
|
||||
chrome.fail(error)
|
||||
let message = (error as? ToolError)?.message ?? error.localizedDescription
|
||||
throw ToolError("Chrome did not open its private debugging pipe: \(message). If byre's Chrome was already running, quit that browser once and retry.")
|
||||
}
|
||||
return (chrome, "started Chrome with byre's profile\n")
|
||||
}
|
||||
|
||||
actor ChromeLink {
|
||||
var conn: Task<(Chrome, String), Error>?
|
||||
|
||||
func get() async throws -> (Chrome, String) {
|
||||
if let conn, let (c, _) = try? await conn.value, !c.lock.withLock({ c.closed }) {
|
||||
if let conn, let (c, _) = try? await conn.value {
|
||||
guard !c.lock.withLock({ c.closed }) else {
|
||||
throw ToolError("Chrome closed its debugging pipe; retry the call after quitting any old byre-profile Chrome browser")
|
||||
}
|
||||
return (c, "")
|
||||
}
|
||||
let t = Task { try await openChrome() }
|
||||
@@ -632,7 +660,7 @@ func shrink(_ png: Data, _ maxEdge: Double) throws -> (Data, String) {
|
||||
return (data as Data, "page viewport, image \(img.width)x\(img.height) scaled from \(full.width)x\(full.height)")
|
||||
}
|
||||
|
||||
func chromeTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
func chromeTool(_ args: [String: Any], saveDir: String?, context: ChromeContext) async throws -> [Content] {
|
||||
let op = try args.require("op", args.string)
|
||||
let maxEdge = args.double("max_edge") ?? 1568
|
||||
if !maxEdge.isFinite || maxEdge < 16 || maxEdge > 8192 {
|
||||
@@ -662,7 +690,7 @@ func chromeTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
let r = try await c.send("Target.createTarget", ["url": "about:blank"])
|
||||
tabId = r.string("targetId")
|
||||
}
|
||||
let (target, t) = try await c.tab(tabId)
|
||||
let (target, t) = try await c.tab(tabId, context: context)
|
||||
switch op {
|
||||
case "tabs":
|
||||
break
|
||||
@@ -729,7 +757,7 @@ func chromeTool(_ args: [String: Any]) async throws -> [Content] {
|
||||
let (png, shotNote) = try shrink(Data(base64Encoded: shot.string("data") ?? "") ?? Data(), maxEdge)
|
||||
log.append(shotNote)
|
||||
if args.bool("save") {
|
||||
log.append("saved to \(try save(png))")
|
||||
log.append("saved to \(try save(png, in: saveDir))")
|
||||
}
|
||||
return [.text(log.joined(separator: "\n")), .png(png)]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user