diff --git a/CLAUDE.md b/CLAUDE.md index 30cdd76..4502534 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1705,7 +1705,7 @@ Priority: process env vars > `~/.config/synctray/.env` > Info.plist. Key vars: ` | `~/.config/synctray/profiles/{shortId}.resync-pending.consumed` | Two-Way (bisync) profiles only — per-token record of which bisync session(s) have already run their one-time resync for the current `.resync-pending` token; deleted together with the marker once every required session (primary, and the fallback's own session on a full remote swap) has consumed it | | `~/.local/bin/synctray-sync.sh` | Shared sync script (all profiles) | | `~/Library/LaunchAgents/com.synctray.sync.{shortId}.plist` | launchd schedule | -| `~/.local/log/synctray-sync-{shortId}.log` | Sync logs | +| `~/.local/log/synctray-sync-{shortId}.log` | Sync logs. `~/.local/log` and `~/.config/synctray/profiles/` are created owner-only (0700) — the logs name every synced file, the profile JSON names remotes, local paths and extra rclone flags — and a directory an earlier version left 0755 is tightened at launch (`SyncSetupService.tightenPrivateDirectoriesIfLoose`). Covered by `ConfigSelfTest` AC-SEC5. | | `/tmp/synctray-sync-{shortId}.lock` | Lock file (prevents concurrent syncs) | | `~/.local/state/synctray/rc/{rcPort}.auth` | Mount mode only — 0600 secret for the streaming mount's rclone RC API. Generated by the sync script, passed to rclone as `RCLONE_RC_USER`/`RCLONE_RC_PASS` (env, never argv), read by `VFSCacheService.rcRequest` for HTTP Basic auth. The RC API never runs `--rc-no-auth` (any local process, or a web page via a cross-origin form POST, could otherwise drive it). Covered by `ConfigSelfTest` AC-SEC2. | | `$(getconf DARWIN_USER_TEMP_DIR)synctray-mount-{shortId}.mode` | Mount mode only — the currently active `MountMode` token (`streaming`/`cache-only-manual`/`cache-only-pending`/`cache-only-offline`), rewritten on every mount start. Lives in the per-user temp dir (`/var/folders/…/T/`, owner-only 0700, cleared at boot; `SyncProfile.privateTempDirectory`), **never** the shared `/tmp`: the script writes it with `echo >`, which follows a symlink another local user could plant at a predictable `/tmp` name (CWE-59). A derived config still naming the old `/tmp` path is rewritten at launch (`SyncSetupService.refreshMountModePathIfChanged`). Covered by `ConfigSelfTest` AC-SEC4. | diff --git a/SyncTray/Models/SyncProfile.swift b/SyncTray/Models/SyncProfile.swift index 7091e2e..ad2e3ea 100644 --- a/SyncTray/Models/SyncProfile.swift +++ b/SyncTray/Models/SyncProfile.swift @@ -169,9 +169,16 @@ struct SyncProfile: Identifiable, Codable, Equatable { "\(NSHomeDirectory())/Library/LaunchAgents/com.synctray.sync.\(shortId).plist" } + /// Directory holding every profile's sync log. Created owner-only (0700) — the logs + /// name every synced file — and tightened at launch by + /// `SyncSetupService.tightenPrivateDirectoriesIfLoose()`. + static var logDirectory: String { + "\(NSHomeDirectory())/.local/log" + } + /// Profile-specific log file var logPath: String { - "\(NSHomeDirectory())/.local/log/synctray-sync-\(shortId).log" + "\(Self.logDirectory)/synctray-sync-\(shortId).log" } /// Profile-specific exclude filter file diff --git a/SyncTray/Services/ConfigSelfTest.swift b/SyncTray/Services/ConfigSelfTest.swift index c5d3921..50618db 100644 --- a/SyncTray/Services/ConfigSelfTest.swift +++ b/SyncTray/Services/ConfigSelfTest.swift @@ -117,6 +117,7 @@ enum ConfigSelfTest { testRCAPIAuthenticated, testShimQuotesExecutablePath, testMountModeFileNotInSharedTmp, + testLogAndProfileDirsOwnerOnly, testRemoteFolderListingKeepsSpaces, testCLIRemoteFolders, testCLITelemetryConsent, @@ -3695,6 +3696,66 @@ enum ConfigSelfTest { return report(name, slug, true) } + // MARK: - AC-SEC5 — the sync-log and profile directories are owner-only + + /// The sync logs name every synced file and the profile JSON names remotes, local paths + /// and extra rclone flags. Earlier versions created both directories with the default + /// mode (0755), so on a Mac whose home folder other accounts can traverse, they could + /// read them (CWE-276). Asserts the launch-time tighten drops group/other bits from a + /// loose directory owned by this user, leaves an already-0700 directory, a symlink (and + /// its target), a regular file and a missing path alone, and that the profile writer + /// creates a new directory 0700. Also pins the once-per-launch call. + private static func testLogAndProfileDirsOwnerOnly() -> Bool { + let name = "AC-SEC5", slug = "log-and-profile-dirs-owner-only" + let fm = FileManager.default + let root = "\(selfTestRoot)/sec5-\(UUID().uuidString)" + defer { try? fm.removeItem(atPath: root) } + + func makeDir(_ path: String, mode: Int) -> Bool { + (try? fm.createDirectory(atPath: path, withIntermediateDirectories: true)) != nil + && (try? fm.setAttributes([.posixPermissions: mode], ofItemAtPath: path)) != nil + } + func mode(_ path: String) -> Int? { + ((try? fm.attributesOfItem(atPath: path))?[.posixPermissions] as? Int).map { $0 & 0o777 } + } + + let loose = "\(root)/loose", tight = "\(root)/tight", target = "\(root)/target" + let link = "\(root)/link", file = "\(root)/file", missing = "\(root)/missing" + guard makeDir(loose, mode: 0o755), makeDir(tight, mode: 0o700), makeDir(target, mode: 0o755), + (try? fm.createSymbolicLink(atPath: link, withDestinationPath: target)) != nil, + fm.createFile(atPath: file, contents: nil, attributes: [.posixPermissions: 0o644]) else { + return report(name, slug, false, "(could not create fixtures under \(root))") + } + + guard SyncSetupService.tightenDirectoryIfLoose(atPath: loose), mode(loose) == 0o700 else { + return report(name, slug, false, "(a 0755 directory was not tightened to 0700: \(String(describing: mode(loose))))") + } + guard !SyncSetupService.tightenDirectoryIfLoose(atPath: tight), mode(tight) == 0o700 else { + return report(name, slug, false, "(an already-0700 directory was changed)") + } + guard !SyncSetupService.tightenDirectoryIfLoose(atPath: link), mode(target) == 0o755 else { + return report(name, slug, false, "(a symlinked directory was followed and changed)") + } + guard !SyncSetupService.tightenDirectoryIfLoose(atPath: file), mode(file) == 0o644 else { + return report(name, slug, false, "(a regular file was changed)") + } + guard !SyncSetupService.tightenDirectoryIfLoose(atPath: missing), !fm.fileExists(atPath: missing) else { + return report(name, slug, false, "(a missing path was created or reported as tightened)") + } + + let profilesDir = "\(root)/profiles" + guard ProfileStore.writeProfileFile(sampleProfile(), in: profilesDir) != nil, + mode(profilesDir) == 0o700 else { + return report(name, slug, false, "(the profile writer created its directory with mode \(String(describing: mode(profilesDir))), not 0700)") + } + + guard let appSource = readSourceFile("SyncTrayApp.swift"), + appSource.contains("SyncSetupService.tightenPrivateDirectoriesIfLoose()") else { + return report(name, slug, false, "(SyncTrayApp no longer tightens the directories at launch)") + } + return report(name, slug, true) + } + // MARK: - AC-CO1 — Cache Only union config + command composition private static func testCacheOnlyUnionConfig() -> Bool { diff --git a/SyncTray/Services/LogWatcher.swift b/SyncTray/Services/LogWatcher.swift index 08e69d2..b804a66 100644 --- a/SyncTray/Services/LogWatcher.swift +++ b/SyncTray/Services/LogWatcher.swift @@ -42,7 +42,9 @@ final class LogWatcher { let fileManager = FileManager.default if !fileManager.fileExists(atPath: logPath) { let directory = (logPath as NSString).deletingLastPathComponent - try? fileManager.createDirectory(atPath: directory, withIntermediateDirectories: true) + try? fileManager.createDirectory( + atPath: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) fileManager.createFile(atPath: logPath, contents: nil) } diff --git a/SyncTray/Services/MigrationRunner.swift b/SyncTray/Services/MigrationRunner.swift index 72d62fa..fec2bb4 100644 --- a/SyncTray/Services/MigrationRunner.swift +++ b/SyncTray/Services/MigrationRunner.swift @@ -318,7 +318,9 @@ struct MigrationV3BlobToPerProfileFiles: ProfileMigration { /// got a file) is OBSERVABLE rather than losing config quietly. @discardableResult static func writeProfileFiles(from profileDicts: [[String: Any]], to directory: String) throws -> WriteResult { - try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + atPath: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) var written = 0 var present = 0 diff --git a/SyncTray/Services/ProfileStore.swift b/SyncTray/Services/ProfileStore.swift index b011dab..ee0f6b9 100644 --- a/SyncTray/Services/ProfileStore.swift +++ b/SyncTray/Services/ProfileStore.swift @@ -124,7 +124,8 @@ final class ProfileStore: ObservableObject { /// `.profile.json` changes, so pruning is safe with the watcher. private func writeProfileFiles() { guard (try? FileManager.default.createDirectory( - atPath: profilesDirectory, withIntermediateDirectories: true)) != nil else { return } + atPath: profilesDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700])) != nil else { return } var writtenFilenames: Set = [] for profile in profiles { @@ -157,7 +158,8 @@ final class ProfileStore: ObservableObject { nonisolated static func writeProfileFile(_ profile: SyncProfile, in directory: String) -> String? { let fm = FileManager.default guard (try? fm.createDirectory( - atPath: directory, withIntermediateDirectories: true)) != nil else { return nil } + atPath: directory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700])) != nil else { return nil } guard var dict = try? encodeProfileDict(profile) else { return nil } // Relative to `directory` (".../profiles"), the schema lives one level diff --git a/SyncTray/Services/SyncSetupService.swift b/SyncTray/Services/SyncSetupService.swift index b4a18d5..e7ffc80 100644 --- a/SyncTray/Services/SyncSetupService.swift +++ b/SyncTray/Services/SyncSetupService.swift @@ -120,6 +120,34 @@ final class SyncSetupService { return onDisk != expected } + /// Tighten the sync-log and profile directories that earlier versions created with the + /// default mode (0755). The logs name every synced file and the profile JSON names + /// remotes, local paths and extra rclone flags, so neither should be listable by other + /// local accounts. New installs create both 0700; this fixes existing ones. Called once + /// per launch, next to `RcloneConfigService.tightenConfigPermissionsIfLoose()`. + static func tightenPrivateDirectoriesIfLoose() { + tightenDirectoryIfLoose(atPath: SyncProfile.logDirectory) + tightenDirectoryIfLoose(atPath: SyncProfile.configDirectory) + } + + /// Drop group/other bits from `path` when it is a real directory owned by this user. + /// Nothing is created and nothing below it is touched. `attributesOfItem` does not + /// follow a final symlink, so a symlinked directory is left alone. Returns true only + /// when the mode was changed. Covered by `ConfigSelfTest` AC-SEC5. + @discardableResult + static func tightenDirectoryIfLoose(atPath path: String) -> Bool { + let fileManager = FileManager.default + guard let attrs = try? fileManager.attributesOfItem(atPath: path), + attrs[.type] as? FileAttributeType == .typeDirectory, + (attrs[.ownerAccountID] as? NSNumber)?.uint32Value == getuid(), + let mode = attrs[.posixPermissions] as? Int, + mode & 0o077 != 0 else { return false } + + // Keep the owner bits as they are and drop only group/other. + return (try? fileManager.setAttributes( + [.posixPermissions: mode & 0o700], ofItemAtPath: path)) != nil + } + /// Generate and install the sync script and launchd plist for a profile /// - Parameters: /// - profile: The sync profile to install @@ -2542,16 +2570,19 @@ extension SyncSetupService { try fm.createDirectory(atPath: binDir, withIntermediateDirectories: true) } - // Create ~/.local/log if needed + // Create ~/.local/log if needed — owner-only: the logs name every synced file let logDir = (profile.logPath as NSString).deletingLastPathComponent if !fm.fileExists(atPath: logDir) { - try fm.createDirectory(atPath: logDir, withIntermediateDirectories: true) + try fm.createDirectory( + atPath: logDir, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) } - // Create ~/.config/synctray/profiles if needed + // Create ~/.config/synctray/profiles if needed — owner-only, like the logs if !fm.fileExists(atPath: SyncProfile.configDirectory) { try fm.createDirectory( - atPath: SyncProfile.configDirectory, withIntermediateDirectories: true) + atPath: SyncProfile.configDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) } // LaunchAgents directory should already exist, but just in case diff --git a/SyncTray/SyncTrayApp.swift b/SyncTray/SyncTrayApp.swift index 9aee435..771f889 100644 --- a/SyncTray/SyncTrayApp.swift +++ b/SyncTray/SyncTrayApp.swift @@ -34,6 +34,8 @@ struct SyncTrayApp: App { // Earlier wizards wrote rclone.conf 0644; tighten it now instead of waiting for the next remote edit. RcloneConfigService.shared.tightenConfigPermissionsIfLoose() + // Same for the sync-log and profile directories, which earlier versions created 0755. + SyncSetupService.tightenPrivateDirectoriesIfLoose() // Initialize telemetry (no-op if disabled) TelemetryService.shared.configure() diff --git a/SyncTray/Views/Settings/ProfileDetailView.swift b/SyncTray/Views/Settings/ProfileDetailView.swift index 7047009..09d588a 100644 --- a/SyncTray/Views/Settings/ProfileDetailView.swift +++ b/SyncTray/Views/Settings/ProfileDetailView.swift @@ -3285,7 +3285,9 @@ struct ProfileDetailView: View { // Ensure log directory exists (append to existing log, don't truncate) let logDir = (syncLogPath as NSString).deletingLastPathComponent - try? fileManager.createDirectory(atPath: logDir, withIntermediateDirectories: true) + try? fileManager.createDirectory( + atPath: logDir, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) // Create file if it doesn't exist if !fileManager.fileExists(atPath: syncLogPath) { @@ -4019,7 +4021,9 @@ struct ProfileDetailView: View { // Ensure log directory exists let logDir = (syncLogPath as NSString).deletingLastPathComponent - try? fileManager.createDirectory(atPath: logDir, withIntermediateDirectories: true) + try? fileManager.createDirectory( + atPath: logDir, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) if !fileManager.fileExists(atPath: syncLogPath) { fileManager.createFile(atPath: syncLogPath, contents: nil) @@ -4427,7 +4431,9 @@ struct ProfileDetailView: View { // Create log directory if needed let logDir = (logPath as NSString).deletingLastPathComponent if !fileManager.fileExists(atPath: logDir) { - try? fileManager.createDirectory(atPath: logDir, withIntermediateDirectories: true) + try? fileManager.createDirectory( + atPath: logDir, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) } // Append to log file