import Foundation import Darwin // MARK: - Pure, testable validation types struct LStatInfo { var uid: uid_t var mode: mode_t // full st_mode var isSymlink: Bool var isDir: Bool var exists: Bool } /// Authoritative result of an lstat call: present, absent (ENOENT), or failed with errno. enum LStatResult { case present(LStatInfo) case absent case failed(errnoCode: Int32) } /// New authoritative provider that never swallows errors. typealias LStatResultProvider = (String) -> LStatResult /// Legacy optional provider kept for existing pure tests: nil == absent (ENOENT). /// New code should use LStatResultProvider. typealias LStatProvider = (String) -> LStatInfo? // MARK: - Trust boundary documentation /* Trust boundary for socket parent-chain validation – tiered model: Tiers (evaluated per existing component, fail-closed on lstat errors): 1) Platform-trusted ancestors – explicit allowlist ONLY: "/", "/Users", "/private", "/var", "/tmp", "/private/tmp", "/var/tmp", "/private/var", "/private/var/tmp" Hard-coded in `platformTrustedRootPaths`. - lstat non-symlink dir (except /var and /tmp which are known macOS symlinks and allowed as symlink) - uid 0 - non-tmp platform paths ("/", "/Users", "/private", "/private/var"): no group/other write (mode & 022 == 0), 0755 allowed - tmp platform paths ("/private/tmp", "/var/tmp", "/private/var/tmp", plus "/tmp","/var" as dirs): uid 0 only, may be 1777 sticky 2) User-owned intermediate ancestors (e.g. $HOME = /Users/, ~/Library, ~/Library/Application Support, ...): - not a symlink - a directory - owned by current uid (getuid()) - no group/other *write* (mode & 022 == 0) -> allows 0700, 0750, 0755 (standard macOS home is 0750 = rwxr-x---) but rejects 0770/0777 or any writable bit Reason: home 0750 is default on some installs; privacy is still enforced by tier 3. 3) Dedicated runtime socket parent – the immediate parent dir of the socket (e.g. .../reyna-cli/privacy): - not a symlink - a directory - owned by current uid - strictly no group/other bits at all (mode & 077 == 0) => 0700 family only, rejects 0750/0755 + socket file itself must be 0600 (enforced in SocketServer bind/chmod) - Never trust arbitrary root-owned intermediate paths outside explicit allowlist. This is the fix for: home 0750 was incorrectly rejected (validator required 0700 for all user components), causing "Refusing socket path: parent component /Users/ has group/other permissions: 750". Now tier 2 allows 0750 for home/intermediates, tier 3 keeps 0700 for the privacy dir. */ let platformTrustedRootPaths: Set = [ "/", "/Users", "/private", "/var", "/tmp", "/private/tmp", "/var/tmp", "/private/var", "/private/var/tmp" ] // Symlink-allowed platform paths – macOS ships /tmp -> private/tmp and /var -> private/var let platformSymlinkAllowedPaths: Set = [ "/tmp", "/var" ] func isRootTrustedPath(_ p: String) -> Bool { return platformTrustedRootPaths.contains(p) } func isSymlinkAllowedPlatformPath(_ p: String) -> Bool { return platformSymlinkAllowedPaths.contains(p) } func rejectIfDotComponentsPure(in socketPath: String) throws { let url = URL(fileURLWithPath: socketPath) for comp in url.pathComponents { if comp == "." || comp == ".." { throw NSError(domain: "SocketServer", code: 20, userInfo: [NSLocalizedDescriptionKey: "Socket path must not contain '.' or '..' components: \(socketPath)"]) } } let standardized = url.standardized.path if standardized != socketPath { let stdComps = URL(fileURLWithPath: standardized).pathComponents let origComps = url.pathComponents if stdComps != origComps { for c in stdComps { if c == "." || c == ".." { throw NSError(domain: "SocketServer", code: 21, userInfo: [NSLocalizedDescriptionKey: "Socket path contains invalid components after standardization"]) } } } } } // MARK: - Core validation using authoritative provider private func lstatInfo(from st: stat) -> LStatInfo { let isSymlink = (st.st_mode & S_IFMT) == S_IFLNK let isDir = (st.st_mode & S_IFMT) == S_IFDIR // For symlink itself, isDir should be false so caller can distinguish return LStatInfo(uid: st.st_uid, mode: st.st_mode, isSymlink: isSymlink, isDir: isSymlink ? false : isDir, exists: true) } /// Single-component authoritative validator reused by both chain validation and ensureParentDirectories. /// This is the sole place that encodes trusted-root vs user-owned policy. /// Tiers: /// 1) platform trusted (explicit allowlist) – uid 0, dir, no g/o write except tmp exemptions, symlink allowed only for /tmp / /var /// 2) user-owned intermediate ancestors – uid current, not symlink, dir, mode & 022 == 0 (allows 0700/0750/0755, rejects writable) /// 3) dedicated runtime parent (immediate socket parent) – uid current, not symlink, dir, mode & 077 == 0 (requires 0700 family) /// /// - For symlink: only /tmp and /var may be symlink (macOS aliases), else reject. /// - For non-dir file: always reject (including /tmp /var as dir target). func validateSingleLStatInfoOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws { if info.isSymlink { if isSymlinkAllowedPlatformPath(path) { return } throw NSError(domain: "SocketServer", code: 23, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) is a symlink"]) } if !info.isDir { // A regular file (or other non-dir) at any parent component, including /tmp /var, must reject throw NSError(domain: "SocketServer", code: 10, userInfo: [NSLocalizedDescriptionKey: "Parent path exists but is not a directory: \(path)"]) } if path == "/" || isRootTrustedPath(path) { if path == "/tmp" || path == "/var" { if info.uid != 0 { throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"]) } return } if path == "/private/tmp" || path == "/var/tmp" || path == "/private/var/tmp" { if info.uid != 0 { throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"]) } return } if info.uid != 0 { throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"]) } if (info.mode & 0o022) != 0 { throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(info.mode & 0o777, radix: 8))"]) } return } if info.uid != currentUID { throw NSError(domain: "SocketServer", code: 24, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) not owned by current uid"]) } let perms = info.mode & 0o777 if isDedicatedRuntimeParent { // Tier 3: dedicated runtime must be exactly 0700 family – no group/other bits if (perms & 0o077) != 0 { throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"]) } } else { // Tier 2: intermediate user-owned – no group/other write (allows 0750/0755, rejects 0770/0777) if (perms & 0o022) != 0 { throw NSError(domain: "SocketServer", code: 25, userInfo: [NSLocalizedDescriptionKey: "Refusing socket path: parent component \(path) has group/other permissions: \(String(perms, radix: 8))"]) } } } func validateParentChainPureResultProvider(socketPath: String, currentUID: uid_t, provider: LStatResultProvider) throws { try rejectIfDotComponentsPure(in: socketPath) let parentURL = URL(fileURLWithPath: socketPath).deletingLastPathComponent() let parentPath = parentURL.path if parentPath.isEmpty || parentPath == "/" { return } let comps = parentURL.pathComponents // starts with "/" var cur = "" for comp in comps { if comp == "/" { cur = "/" continue } if cur == "/" { cur = "/" + comp } else if cur.isEmpty { cur = comp } else { cur = cur + "/" + comp } let result = provider(cur) switch result { case .absent: continue case .failed(let errnoCode): throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for parent component \(cur): \(String(cString: strerror(errnoCode)))"]) case .present(let info): let isDedicated = (cur == parentPath) try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicated) } } } /// Pure parent-chain validator with injectable lstat and uid (legacy nil==ENOENT shim). /// /// - Parameters: /// - socketPath: absolute socket path /// - currentUID: uid of current process /// - provider: returns LStatInfo? (nil if ENOENT, else info). Must use lstat, not stat. /// - Throws: on policy violation func validateParentChainPure(socketPath: String, currentUID: uid_t, provider: LStatProvider) throws { // Adapt legacy optional provider into authoritative result provider let adapted: LStatResultProvider = { path in if let info = provider(path) { return .present(info) } else { return .absent } } try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: adapted) } // MARK: - Live lstat adapter /// Authoritative live lstat that never swallows non-ENOENT errors. func liveLStatResultProvider(path: String) -> LStatResult { var st = stat() if lstat(path, &st) != 0 { if errno == ENOENT { return .absent } return .failed(errnoCode: errno) } return .present(lstatInfo(from: st)) } /// Legacy optional lstat provider. Now fail-closed: returns nil ONLY for ENOENT, and for /// other errors returns a present but invalid sentinel that will cause validation to reject /// (never treated as missing). Prefer liveLStatResultProvider. func liveLStatProvider(path: String) -> LStatInfo? { switch liveLStatResultProvider(path: path) { case .absent: return nil case .present(let info): return info case .failed: // Fail-closed sentinel: not a directory, wrong uid, triggers rejection if misused directly // We return an info that will be rejected as non-directory return LStatInfo(uid: uid_t.max, mode: 0, isSymlink: false, isDir: false, exists: true) } } func validateExistingParentChainLive(for socketPath: String) throws { let currentUID = getuid() // Single authoritative scan using liveLStatResultProvider; no pre-scan duplicate, no nil-swallow try validateParentChainPureResultProvider(socketPath: socketPath, currentUID: currentUID, provider: { liveLStatResultProvider(path: $0) }) }