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>
This commit is contained in:
Omar Shahine 2026-05-06 14:13:31 -07:00 committed by GitHub
parent 2d7b506d17
commit 243226951f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 280 additions and 20 deletions

View File

@ -7,6 +7,11 @@
chat/group lifecycle RPC methods after the BlueBubbles-inspired bridge port
regressed on Tahoe (#101, thanks @omarshahine).
### Security
- fix: harden bridge IPC queue directories and attachment paths against
symlink traversal while preserving trusted macOS system aliases like `/tmp`
(#105, thanks @omarshahine).
## 0.7.2 - 2026-05-06
### Release Packaging

View File

@ -126,6 +126,9 @@ public final class IMsgBridgeClient: @unchecked Sendable {
}
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 }
@ -133,7 +136,14 @@ public final class IMsgBridgeClient: @unchecked Sendable {
}
do {
try FileManager.default.createDirectory(
atPath: path, withIntermediateDirectories: true)
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)")
}

View File

@ -123,22 +123,44 @@ public final class MessagesLauncher: @unchecked Sendable {
// Pre-create v2 RPC queue directories so the dylib can FSEvent-watch them
// immediately on startup (FSEventStream registration on a missing path
// silently fails to deliver events).
try? FileManager.default.createDirectory(
atPath: bridgeInboxDirectory, withIntermediateDirectories: true)
try? FileManager.default.createDirectory(
atPath: bridgeOutboxDirectory, withIntermediateDirectories: true)
cleanQueueDirectory(bridgeInboxDirectory)
cleanQueueDirectory(bridgeOutboxDirectory)
try ensureSecureQueueDirectory(bridgeInboxDirectory)
try ensureSecureQueueDirectory(bridgeOutboxDirectory)
try cleanQueueDirectory(bridgeInboxDirectory)
try cleanQueueDirectory(bridgeOutboxDirectory)
try launchWithInjection()
try waitForReady(timeout: 15.0)
}
private func cleanQueueDirectory(_ path: String) {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: path)
else { return }
private func ensureSecureQueueDirectory(_ path: String) throws {
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)")
}
do {
try FileManager.default.createDirectory(
atPath: path,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700])
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError(
"RPC queue path traverses a symlink (post-mkdir): \(path)")
}
try FileManager.default.setAttributes(
[.posixPermissions: 0o700], ofItemAtPath: path)
} catch let error as MessagesLauncherError {
throw error
} catch {
throw MessagesLauncherError.socketError("mkdir \(path): \(error.localizedDescription)")
}
}
private func cleanQueueDirectory(_ path: String) throws {
if SecurePath.hasSymlinkComponent(path) {
throw MessagesLauncherError.socketError("RPC queue path traverses a symlink: \(path)")
}
let entries = try FileManager.default.contentsOfDirectory(atPath: path)
for entry in entries {
try? FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry))
try FileManager.default.removeItem(atPath: (path as NSString).appendingPathComponent(entry))
}
}

View File

@ -0,0 +1,62 @@
import Darwin
import Foundation
/// Lexical-walk symlink detector. Used wherever we accept a filesystem path
/// from outside the dylib (RPC inbox dir, attachment paths) and want to refuse
/// any path that traverses a symbolic link, including parent components.
///
/// `realpath()` alone isn't sufficient: a same-UID attacker who can write to
/// our RPC inbox could otherwise symlink an arbitrary file (a credential file,
/// a password manager DB) into a location they control and have Messages.app
/// exfiltrate it as an attachment. Comparing the resolved path against the
/// lexical input is fragile too macOS rewrites `/tmp` to `/private/tmp`,
/// breaking that check for legitimate paths. Walking each component with
/// `lstat()` and refusing the path on any `S_IFLNK` is the robust answer.
public enum SecurePath {
private static func normalizingTrustedSystemAliasPrefix(_ path: String) -> String {
let aliases = [
"/tmp": "/private/tmp",
"/var": "/private/var",
"/etc": "/private/etc",
]
for (alias, canonical) in aliases {
if path == alias {
return canonical
}
if path.hasPrefix(alias + "/") {
return canonical + path.dropFirst(alias.count)
}
}
return path
}
/// Returns true if any component of `path` (after tilde expansion and CWD
/// resolution for relative paths) is a symbolic link. Final component
/// included.
public static func hasSymlinkComponent(_ path: String) -> Bool {
var lexicalPath = (path as NSString).expandingTildeInPath
if !lexicalPath.hasPrefix("/") {
lexicalPath =
(FileManager.default.currentDirectoryPath as NSString)
.appendingPathComponent(lexicalPath)
}
lexicalPath = normalizingTrustedSystemAliasPrefix(lexicalPath)
let components = (lexicalPath as NSString).pathComponents
guard !components.isEmpty else { return false }
var cursor = components.first == "/" ? "/" : ""
for component in components where component != "/" && !component.isEmpty {
cursor = (cursor as NSString).appendingPathComponent(component)
var info = stat()
if lstat(cursor, &info) != 0 {
continue
}
if (info.st_mode & S_IFMT) == S_IFLNK {
return true
}
}
return false
}
}

View File

@ -104,6 +104,98 @@ static void debugLog(NSString *fmt, ...) {
if (fp) { fputs(line.UTF8String, fp); fclose(fp); }
}
#pragma mark - Path Hardening
// Returns YES if any component of `path` (after tilde expansion and CWD
// resolution for relative paths) is a symbolic link, including the final
// component. Mirrors `SecurePath.hasSymlinkComponent` in IMsgCore: realpath()
// alone isn't enough because macOS rewrites `/tmp` -> `/private/tmp`, breaking
// any "resolved == lexical" check. Walking each component with lstat() and
// rejecting on S_IFLNK is the robust answer.
//
// Used to refuse RPC queue dirs and attachment paths that traverse a symlink
// at any level, closing the same-UID-attacker exfiltration path where someone
// drops a symlink to ~/.ssh/id_rsa or a password-manager DB and has Messages
// send it as an attachment to an attacker-controlled handle.
static NSString *normalizeTrustedSystemAliasPrefix(NSString *path) {
NSDictionary<NSString *, NSString *> *aliases = @{
@"/tmp": @"/private/tmp",
@"/var": @"/private/var",
@"/etc": @"/private/etc",
};
for (NSString *alias in aliases) {
if ([path isEqualToString:alias]) {
return aliases[alias];
}
NSString *prefix = [alias stringByAppendingString:@"/"];
if ([path hasPrefix:prefix]) {
return [aliases[alias] stringByAppendingString:
[path substringFromIndex:alias.length]];
}
}
return path;
}
static BOOL pathHasSymlinkComponent(NSString *path) {
NSString *lexicalPath = [path stringByExpandingTildeInPath];
if (!lexicalPath.isAbsolutePath) {
lexicalPath = [[[NSFileManager defaultManager] currentDirectoryPath]
stringByAppendingPathComponent:lexicalPath];
}
lexicalPath = normalizeTrustedSystemAliasPrefix(lexicalPath);
NSArray *components = [lexicalPath pathComponents];
if (components.count == 0) return NO;
NSString *cursor = [components.firstObject isEqualToString:@"/"] ? @"/" : @"";
for (NSString *component in components) {
if ([component isEqualToString:@"/"] || component.length == 0) continue;
cursor = [cursor stringByAppendingPathComponent:component];
struct stat st;
if (lstat([cursor fileSystemRepresentation], &st) != 0) {
continue;
}
if (S_ISLNK(st.st_mode)) {
return YES;
}
}
return NO;
}
static BOOL ensureSecureDirectory(NSString *path, NSError **error) {
if (pathHasSymlinkComponent(path)) {
if (error) {
*error = [NSError errorWithDomain:@"imsg.bridge"
code:1
userInfo:@{
NSLocalizedDescriptionKey: @"RPC queue path traverses a symlink"
}];
}
return NO;
}
NSDictionary *secureMode = @{ NSFilePosixPermissions: @(0700) };
BOOL ok = [[NSFileManager defaultManager]
createDirectoryAtPath:path
withIntermediateDirectories:YES
attributes:secureMode
error:error];
if (!ok) return NO;
if (pathHasSymlinkComponent(path)) {
if (error) {
*error = [NSError errorWithDomain:@"imsg.bridge"
code:2
userInfo:@{
NSLocalizedDescriptionKey: @"RPC queue path traverses a symlink (post-mkdir)"
}];
}
return NO;
}
chmod([path fileSystemRepresentation], 0700);
return YES;
}
#pragma mark - Selector Probes
// Populated at startup by probeSelectors(). Surfaced via the `status` action so
@ -1908,6 +2000,19 @@ static NSDictionary *handleSendAttachment(NSInteger requestId, NSDictionary *par
if (!chatGuid.length) return errorResponse(requestId, @"Missing chatGuid");
if (!filePath.length) return errorResponse(requestId, @"Missing filePath");
NSError *attrErr = nil;
NSDictionary *attrs = [[NSFileManager defaultManager]
attributesOfItemAtPath:filePath error:&attrErr];
if (!attrs) {
return errorResponse(requestId,
[NSString stringWithFormat:@"File not found: %@", filePath]);
}
if ([attrs[NSFileType] isEqualToString:NSFileTypeSymbolicLink]) {
return errorResponse(requestId, @"Symlinked attachment paths are not allowed");
}
if (pathHasSymlinkComponent(filePath)) {
return errorResponse(requestId, @"Attachment path traverses a symlink");
}
if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
return errorResponse(requestId,
[NSString stringWithFormat:@"File not found: %@", filePath]);
@ -3111,15 +3216,18 @@ static void startV2InboxWatcher(void) {
initFilePaths();
// Ensure the queue dirs exist (CLI also pre-creates them, but be defensive
// in case a v2-only run happened).
[[NSFileManager defaultManager] createDirectoryAtPath:kRpcInDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
[[NSFileManager defaultManager] createDirectoryAtPath:kRpcOutDir
withIntermediateDirectories:YES
attributes:nil
error:nil];
// in case a v2-only run happened). Mode 0700 keeps other UIDs / sandboxed
// peers from being able to enumerate or inject RPC requests, and the
// symlink check refuses to operate if any path component traverses a
// link, see pathHasSymlinkComponent for rationale.
NSError *secureDirError = nil;
if (!ensureSecureDirectory(kRpcDir, &secureDirError) ||
!ensureSecureDirectory(kRpcInDir, &secureDirError) ||
!ensureSecureDirectory(kRpcOutDir, &secureDirError)) {
NSLog(@"[imsg-bridge v2] Refusing insecure RPC queue path: %@",
secureDirError.localizedDescription);
return;
}
NSLog(@"[imsg-bridge v2] Inbox: %@", kRpcInDir);
NSLog(@"[imsg-bridge v2] Outbox: %@", kRpcOutDir);

View File

@ -83,6 +83,59 @@ func attachmentResolverLeavesUnsupportedFilesUnconverted() throws {
#expect(meta.convertedMimeType == nil)
}
@Test
func securePathDetectsFinalSymlink() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let target = dir.appendingPathComponent("target.txt")
let link = dir.appendingPathComponent("link.txt")
try Data("hello".utf8).write(to: target)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target)
#expect(SecurePath.hasSymlinkComponent(target.path) == false)
#expect(SecurePath.hasSymlinkComponent(link.path) == true)
}
@Test
func securePathDetectsParentSymlink() throws {
let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let realParent = dir.appendingPathComponent("real")
let linkParent = dir.appendingPathComponent("linked")
try FileManager.default.createDirectory(at: realParent, withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: linkParent, withDestinationURL: realParent)
let realChild = realParent.appendingPathComponent("child.txt")
let linkedChild = linkParent.appendingPathComponent("child.txt")
try Data("hello".utf8).write(to: realChild)
#expect(SecurePath.hasSymlinkComponent(realChild.path) == false)
#expect(SecurePath.hasSymlinkComponent(linkedChild.path) == true)
}
@Test
func securePathAllowsTrustedSystemAliasPrefixes() throws {
let privateTmp = URL(fileURLWithPath: "/private/tmp", isDirectory: true)
let dirName = "imsg-secure-path-\(UUID().uuidString)"
let realDir = privateTmp.appendingPathComponent(dirName)
try FileManager.default.createDirectory(at: realDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: realDir) }
let realFile = realDir.appendingPathComponent("target.txt")
try Data("hello".utf8).write(to: realFile)
let aliasFile = "/tmp/\(dirName)/target.txt"
#expect(SecurePath.hasSymlinkComponent(aliasFile) == false)
let link = realDir.appendingPathComponent("link.txt")
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: realFile)
#expect(SecurePath.hasSymlinkComponent("/tmp/\(dirName)/link.txt") == true)
}
@Test
func iso8601ParserParsesFormats() {
let fractional = "2024-01-02T03:04:05.678Z"