Skip to content
Open
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
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,7 @@
**Vulnerability:** External shell command executed in `listLocalSnapshots()` triggered a deadlock when `tmutil` output exceeded 64KB, because stdout and stderr were read synchronously inside the process termination handler.
**Learning:** In Swift, reading from a process pipe synchronously inside a `terminationHandler` can result in a permanent deadlock if the child blocks writing to a full pipe, preventing it from exiting.
**Prevention:** Asynchronously drain pipes continuously while the process is running using background queues.
## 2024-05-24 - TOCTOU Vulnerability via FileManager Attributes
**Vulnerability:** Found `FileManager.default.setAttributes` used to set permissions on directories and files, exposing a Time-of-Check to Time-of-Use (TOCTOU) symlink vulnerability.
**Learning:** `FileManager.default.setAttributes` internally calls `chmod`, which follows symlinks. An attacker can swap the target with a symlink between creation and permission setting, potentially granting elevated privileges to unintended files.
**Prevention:** Avoid `FileManager.default.setAttributes` when hardening file or directory permissions. Instead, securely obtain a file descriptor using `open()` with `O_NOFOLLOW` (and `O_CLOEXEC`), and then apply permissions using `fchmod()`.
17 changes: 13 additions & 4 deletions Sources/Cacheout/Headless/DaemonMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,19 @@ public actor DaemonMode: StatusSocket.DataSource {
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
try FileManager.default.setAttributes(
[.posixPermissions: 0o700],
ofItemAtPath: config.stateDir.path
)

// Safely set directory permissions using open(2) and fchmod(2)
// to prevent TOCTOU symlink attacks.
let success = config.stateDir.withUnsafeFileSystemRepresentation { pathPtr -> Bool in
guard let ptr = pathPtr else { return false }
let fd = open(ptr, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)
guard fd >= 0 else { return false }
defer { close(fd) }
return fchmod(fd, S_IRWXU) == 0
}
if !success {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: nil)
}
} catch {
logger.error("Failed to create/secure state directory: \(error.localizedDescription, privacy: .public)")
Foundation.exit(1)
Expand Down
16 changes: 15 additions & 1 deletion Sources/Cacheout/Headless/StatusSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,19 @@ public final class StatusSocket: @unchecked Sendable {
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true, attributes: [
.posixPermissions: 0o700
])
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: dir)

// Safely set directory permissions using open(2) and fchmod(2)
// to prevent TOCTOU symlink attacks where an attacker swaps the directory.
let success = URL(fileURLWithPath: dir).withUnsafeFileSystemRepresentation { pathPtr -> Bool in
guard let ptr = pathPtr else { return false }
let fd = open(ptr, O_RDONLY | O_NOFOLLOW | O_CLOEXEC)
guard fd >= 0 else { return false }
defer { close(fd) }
return fchmod(fd, S_IRWXU) == 0
}
if !success {
throw StatusSocketError.socketCreationFailed(errno)
}

// Remove stale socket if present
unlink(socketPath)
Expand Down Expand Up @@ -157,6 +169,8 @@ public final class StatusSocket: @unchecked Sendable {
}

// Verify socket permissions are 0600
// (Note: The parent directory is strictly 0700, protecting against symlink planting.
// We cannot use open(2) on a socket file on macOS, so we rely on umask and chmod).
do {
let attrs = try FileManager.default.attributesOfItem(atPath: socketPath)
if let perms = attrs[.posixPermissions] as? Int, perms != 0o600 {
Expand Down
24 changes: 16 additions & 8 deletions Sources/CacheoutHelperLib/SysctlJournal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,14 +307,22 @@ public final class SysctlJournal {
do {
let data = try PropertyListEncoder().encode(state)

// Write to temp file (non-atomic β€” we control the rename ourselves).
try data.write(to: tmpURL)

// Set permissions to 0600 (root-only) on temp file before rename.
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: tmpURL.path
)
// Write to temp file securely using open(2) with O_CREAT and 0600 permissions
// to avoid TOCTOU vulnerability where file is briefly accessible before chmod.
let success = tmpURL.withUnsafeFileSystemRepresentation { pathPtr -> Bool in
guard let ptr = pathPtr else { return false }
let fd = open(ptr, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, S_IRUSR | S_IWUSR)
guard fd >= 0 else { return false }
Comment on lines +314 to +315
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reapply 0600 when temp journal file already exists

Opening the temp file with O_CREAT | O_TRUNC does not reset permissions on an existing file, so a pre-existing .tmp with broader mode (for example from a prior run or custom writable directory) will keep those broader bits and then be atomically renamed into the live journal. This is a regression from the previous setAttributes(...0600...) step and can expose journal contents to unintended users unless you fchmod after open or use safer exclusive creation logic.

Useful? React with πŸ‘Β / πŸ‘Ž.

let bytesWritten = data.withUnsafeBytes { buffer in
write(fd, buffer.baseAddress, buffer.count)
}
Comment on lines +316 to +318
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Loop on write to avoid partial journal flushes

The new persistence path performs a single write and treats anything short of data.count as failure; on POSIX, write can return short counts or EINTR even for regular files. In those cases this drops a flush and returns false, which can silently lose rollback state under signal-heavy or resource-constrained conditions. The old Data.write path handled full-buffer writes; this should similarly retry until all bytes are written or a non-retryable error occurs.

Useful? React with πŸ‘Β / πŸ‘Ž.

close(fd)
return bytesWritten == data.count
}
guard success else {
try? FileManager.default.removeItem(at: tmpURL)
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: nil)
}

// Atomic rename(2) β€” atomicity on APFS/HFS+.
if rename(tmpURL.path, url.path) != 0 {
Expand Down
Loading