imsg/Sources/IMsgCore/IMsgBridgeClient.swift
Omar Shahine 243226951f
fix(security): clamp IPC dirs to 0700 and reject symlinked paths (#105)
* 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>
2026-05-06 22:13:31 +01:00

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)")
}
}
}