From 9ad53e32e5daf27113e697cbfdb4172ea97eb261 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 07:18:13 +0000 Subject: [PATCH] Fix TOCTOU symlink vulnerability in log cleanup Replaced vulnerable Foundation file APIs (`FileHandle(forWritingTo:)` and `String.write(to:atomically:)`) with a secure POSIX `open()` call. By using `O_CREAT | O_WRONLY | O_APPEND | O_NOFOLLOW | O_CLOEXEC` flags, we explicitly refuse to follow symlinks, preventing a Time-of-Check to Time-of-Use (TOCTOU) vulnerability where an attacker could overwrite sensitive files by creating a symlink in the `~/.cacheout` directory. Co-authored-by: acebytes <2820910+acebytes@users.noreply.github.com> --- .jules/sentinel.md | 4 ++++ Sources/Cacheout/Cleaner/CacheCleaner.swift | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index fd58571..456a5f2 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -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. +## 2026-05-21 - TOCTOU Symlink Vulnerability in Logging +**Vulnerability:** Found `FileHandle(forWritingTo:)` and `String.write(to:atomically:)` being used for writing to a log file, which follow symlinks by default. +**Learning:** High-level Swift file APIs follow symlinks by default, making them vulnerable to Time-of-Check to Time-of-Use (TOCTOU) symlink attacks if the directory is potentially untrusted. +**Prevention:** To securely create or append to files in potentially untrusted directories, use POSIX `open(2)` with flags such as `O_CREAT | O_WRONLY | O_APPEND | O_NOFOLLOW | O_CLOEXEC` to explicitly refuse symlinks, then wrap the resulting file descriptor in a `FileHandle`. diff --git a/Sources/Cacheout/Cleaner/CacheCleaner.swift b/Sources/Cacheout/Cleaner/CacheCleaner.swift index 2111e6a..1d372c6 100644 --- a/Sources/Cacheout/Cleaner/CacheCleaner.swift +++ b/Sources/Cacheout/Cleaner/CacheCleaner.swift @@ -34,6 +34,7 @@ import Foundation import AppKit +import Darwin actor CacheCleaner { private let fileManager = FileManager.default @@ -261,12 +262,19 @@ actor CacheCleaner { let size = ByteCountFormatter.sharedFile.string(fromByteCount: bytesFreed) let entry = "[\(ISO8601DateFormatter.shared.string(from: Date()))] Cleaned \(category): \(size)\n" - if let handle = try? FileHandle(forWritingTo: logFile) { - handle.seekToEndOfFile() + let fd = logFile.withUnsafeFileSystemRepresentation { pathPtr -> Int32 in + guard let pathPtr = pathPtr else { return -1 } + return open(pathPtr, O_CREAT | O_WRONLY | O_APPEND | O_NOFOLLOW | O_CLOEXEC, 0o600) + } + + if fd != -1 { + let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) handle.write(entry.data(using: .utf8) ?? Data()) - handle.closeFile() - } else { - try? entry.write(to: logFile, atomically: true, encoding: .utf8) + if #available(macOS 10.15.4, *) { + try? handle.close() + } else { + handle.closeFile() + } } } }