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:
parent
2d7b506d17
commit
243226951f
@ -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
|
||||
|
||||
@ -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)")
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
62
Sources/IMsgCore/SecurePath.swift
Normal file
62
Sources/IMsgCore/SecurePath.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user