* fix(security): clamp IPC dirs to 0700 and reject symlinked paths Threat model: a same-UID attacker (another user process, a sandboxed peer that can reach the home dir) can drop a file or symlink into the RPC inbox, or supply an attachment path that points at a sensitive file via a parent-directory symlink, and have Messages.app exfiltrate it as an attachment to an attacker-controlled handle. Mitigations applied at every IPC boundary: - Mode 0700 on .imsg-rpc/, .imsg-rpc/in/, .imsg-rpc/out/ creation. Final chmod handles dirs that already existed 0755 from prior unsandboxed runs. - Refuse any RPC queue path or attachment path where any component (final or parent) is a symbolic link. Walking each component with lstat() — done by SecurePath.hasSymlinkComponent in IMsgCore and the pathHasSymlinkComponent twin in the dylib — catches parent-directory links that realpath()-vs-lexical comparison misses (macOS rewrites /tmp -> /private/tmp, breaking that approach for legitimate paths). - Strict throws for queue dir creation and cleanup in MessagesLauncher (was `try?` swallowing errors), and for ensureDirectory in IMsgBridgeClient. Bridge refuses to start instead of operating on an insecure path. Tests cover both final-component and parent-component symlink detection in SecurePath. Toolchain on this machine lacks swift-testing; tests build clean and ship to be run by Xcode on the maintainer's box. * fix: allow trusted system path aliases --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
152 lines
5.2 KiB
Swift
152 lines
5.2 KiB
Swift
import Foundation
|
|
|
|
/// One-shot RPC client for the v2 bridge protocol.
|
|
///
|
|
/// Each call atomically drops a `<uuid>.json` request file into
|
|
/// `~/Library/Containers/com.apple.MobileSMS/Data/.imsg-rpc/in/`, then polls
|
|
/// `out/<uuid>.json` until the dylib responds (or `timeout` elapses).
|
|
///
|
|
/// The dylib is shared across CLI invocations: many concurrent `imsg`
|
|
/// processes can drop requests at once and each gets routed back to the
|
|
/// correct caller via the UUID. There is no global lock on the CLI side.
|
|
public final class IMsgBridgeClient: @unchecked Sendable {
|
|
public static let shared = IMsgBridgeClient(launcher: MessagesLauncher.shared)
|
|
|
|
private let launcher: MessagesLauncher
|
|
private let useLegacyIPC: Bool
|
|
|
|
/// Polling cadence while waiting for a response file to appear.
|
|
private let pollInterval: TimeInterval = 0.05
|
|
|
|
public init(launcher: MessagesLauncher, useLegacyIPC: Bool? = nil) {
|
|
self.launcher = launcher
|
|
if let override = useLegacyIPC {
|
|
self.useLegacyIPC = override
|
|
} else {
|
|
let env = ProcessInfo.processInfo.environment["IMSG_BRIDGE_LEGACY_IPC"]
|
|
self.useLegacyIPC = (env == "1" || env == "true")
|
|
}
|
|
}
|
|
|
|
/// Whether the dylib is currently injected and has published its ready lock.
|
|
public func isReady() -> Bool {
|
|
launcher.hasReadyLockFile()
|
|
}
|
|
|
|
// MARK: - High-level API
|
|
|
|
/// Invoke a v2 bridge action and return its `data` payload on success.
|
|
/// Legacy single-file IPC is only used when explicitly requested through
|
|
/// `IMSG_BRIDGE_LEGACY_IPC=1`.
|
|
public func invoke(
|
|
action: BridgeAction,
|
|
params: [String: Any] = [:],
|
|
timeout: TimeInterval = IMsgBridgeProtocol.defaultResponseTimeout
|
|
) async throws -> [String: Any] {
|
|
if useLegacyIPC {
|
|
try launcher.ensureRunning()
|
|
return try await invokeLegacy(action: action, params: params)
|
|
}
|
|
|
|
try launcher.ensureLaunched()
|
|
return try await invokeV2(action: action, params: params, timeout: timeout)
|
|
}
|
|
|
|
// MARK: - v2 path
|
|
|
|
private func invokeV2(
|
|
action: BridgeAction,
|
|
params: [String: Any],
|
|
timeout: TimeInterval
|
|
) async throws -> [String: Any] {
|
|
let id = UUID().uuidString
|
|
let envelope: [String: Any] = [
|
|
"v": IMsgBridgeProtocol.version,
|
|
"id": id,
|
|
"action": action.rawValue,
|
|
"params": params,
|
|
]
|
|
|
|
let inboxDir = launcher.bridgeInboxDirectory
|
|
let outboxDir = launcher.bridgeOutboxDirectory
|
|
try ensureDirectory(inboxDir)
|
|
try ensureDirectory(outboxDir)
|
|
|
|
let tmp = (inboxDir as NSString).appendingPathComponent("\(id).tmp")
|
|
let final = (inboxDir as NSString).appendingPathComponent("\(id).json")
|
|
let outPath = (outboxDir as NSString).appendingPathComponent("\(id).json")
|
|
|
|
let payload = try JSONSerialization.data(withJSONObject: envelope, options: [])
|
|
try payload.write(to: URL(fileURLWithPath: tmp))
|
|
try FileManager.default.moveItem(atPath: tmp, toPath: final)
|
|
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
try await Task.sleep(nanoseconds: UInt64(pollInterval * 1_000_000_000))
|
|
guard
|
|
let data = try? Data(contentsOf: URL(fileURLWithPath: outPath)),
|
|
data.count > 1
|
|
else { continue }
|
|
// Best-effort cleanup; ignore failures (dylib may also unlink).
|
|
try? FileManager.default.removeItem(atPath: outPath)
|
|
|
|
guard
|
|
let raw = try? JSONSerialization.jsonObject(with: data, options: [])
|
|
as? [String: Any]
|
|
else {
|
|
throw IMsgBridgeError.malformedResponse("non-object body")
|
|
}
|
|
let response = try BridgeResponse.parse(raw)
|
|
if response.success {
|
|
return response.data
|
|
}
|
|
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
|
|
}
|
|
|
|
try? FileManager.default.removeItem(atPath: final)
|
|
throw IMsgBridgeError.timeout(action: action.rawValue)
|
|
}
|
|
|
|
// MARK: - Legacy path
|
|
|
|
private func invokeLegacy(
|
|
action: BridgeAction,
|
|
params: [String: Any]
|
|
) async throws -> [String: Any] {
|
|
do {
|
|
let raw = try await launcher.sendCommand(action: action.rawValue, params: params)
|
|
let response = try BridgeResponse.parse(raw)
|
|
if response.success {
|
|
return response.data
|
|
}
|
|
throw IMsgBridgeError.dylibReturnedError(response.error ?? "unknown")
|
|
} catch let error as MessagesLauncherError {
|
|
throw IMsgBridgeError.bridgeNotReady(error.description)
|
|
}
|
|
}
|
|
|
|
private func ensureDirectory(_ path: String) throws {
|
|
if SecurePath.hasSymlinkComponent(path) {
|
|
throw IMsgBridgeError.ioError("\(path) traverses a symlink")
|
|
}
|
|
var isDir: ObjCBool = false
|
|
if FileManager.default.fileExists(atPath: path, isDirectory: &isDir) {
|
|
if isDir.boolValue { return }
|
|
throw IMsgBridgeError.ioError("\(path) exists and is not a directory")
|
|
}
|
|
do {
|
|
try FileManager.default.createDirectory(
|
|
atPath: path,
|
|
withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700])
|
|
if SecurePath.hasSymlinkComponent(path) {
|
|
throw IMsgBridgeError.ioError("\(path) traverses a symlink (post-mkdir)")
|
|
}
|
|
} catch let error as IMsgBridgeError {
|
|
throw error
|
|
} catch {
|
|
throw IMsgBridgeError.ioError("mkdir \(path): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|