diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c92e4d..d03487b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/IMsgCore/IMsgBridgeClient.swift b/Sources/IMsgCore/IMsgBridgeClient.swift index f121f78..789948d 100644 --- a/Sources/IMsgCore/IMsgBridgeClient.swift +++ b/Sources/IMsgCore/IMsgBridgeClient.swift @@ -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)") } diff --git a/Sources/IMsgCore/MessagesLauncher.swift b/Sources/IMsgCore/MessagesLauncher.swift index df2ac49..f34f75a 100644 --- a/Sources/IMsgCore/MessagesLauncher.swift +++ b/Sources/IMsgCore/MessagesLauncher.swift @@ -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)) } } diff --git a/Sources/IMsgCore/SecurePath.swift b/Sources/IMsgCore/SecurePath.swift new file mode 100644 index 0000000..05153a5 --- /dev/null +++ b/Sources/IMsgCore/SecurePath.swift @@ -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 + } +} diff --git a/Sources/IMsgHelper/IMsgInjected.m b/Sources/IMsgHelper/IMsgInjected.m index 04c79fa..504ff38 100644 --- a/Sources/IMsgHelper/IMsgInjected.m +++ b/Sources/IMsgHelper/IMsgInjected.m @@ -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 *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); diff --git a/Tests/IMsgCoreTests/UtilityTests.swift b/Tests/IMsgCoreTests/UtilityTests.swift index 628897d..5515e25 100644 --- a/Tests/IMsgCoreTests/UtilityTests.swift +++ b/Tests/IMsgCoreTests/UtilityTests.swift @@ -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"