Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
9 changes: 8 additions & 1 deletion SyncTray/Models/SyncProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions SyncTray/Services/ConfigSelfTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ enum ConfigSelfTest {
testRCAPIAuthenticated,
testShimQuotesExecutablePath,
testMountModeFileNotInSharedTmp,
testLogAndProfileDirsOwnerOnly,
testRemoteFolderListingKeepsSpaces,
testCLIRemoteFolders,
testCLITelemetryConsent,
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion SyncTray/Services/LogWatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
4 changes: 3 additions & 1 deletion SyncTray/Services/MigrationRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions SyncTray/Services/ProfileStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
for profile in profiles {
Expand Down Expand Up @@ -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
Expand Down
39 changes: 35 additions & 4 deletions SyncTray/Services/SyncSetupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions SyncTray/SyncTrayApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
12 changes: 9 additions & 3 deletions SyncTray/Views/Settings/ProfileDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading