diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..9218f63 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2026-04-02 - Prevent Path Traversal in File I/O +**Vulnerability:** The `StatusSocket` daemon accepted user-provided file paths for configuration validation (`handleValidateConfig`) without checking if the resolved path escaped the intended directory scope. This allowed local file inclusion and path traversal attacks (e.g., `../../../../../etc/passwd`) because `expandingTildeInPath` does not resolve parent directory (`..`) components. +**Learning:** In Swift, relying solely on `expandingTildeInPath` or `lstat` to validate user-supplied file paths is insufficient. Parent directory references must be resolved to their canonical absolute paths before any security boundaries can be enforced. +**Prevention:** Always use `(path as NSString).standardizingPath` to resolve `..` components and follow up with a prefix check (`hasPrefix(allowedDirectoryPath)`) to ensure the normalized path strictly resides within the expected security boundary. diff --git a/Sources/Cacheout/Headless/StatusSocket.swift b/Sources/Cacheout/Headless/StatusSocket.swift index 12c516e..ee81f54 100644 --- a/Sources/Cacheout/Headless/StatusSocket.swift +++ b/Sources/Cacheout/Headless/StatusSocket.swift @@ -474,14 +474,25 @@ public final class StatusSocket: @unchecked Sendable { } let expandedPath = (path as NSString).expandingTildeInPath + let standardizedPath = (expandedPath as NSString).standardizingPath + let cacheoutDirPath = (FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cacheout").path as NSString).standardizingPath + + // Ensure path traversal is not attempting to escape ~/.cacheout/ + guard standardizedPath.hasPrefix(cacheoutDirPath + "/") else { + sendSuccessResponse(fd: fd, data: [ + "valid": false, + "errors": ["Configuration file must be located within ~/.cacheout/"], + ] as [String: Any]) + return + } // lstat to reject symlinks to special files, FIFOs, devices, etc. var sb = Darwin.stat() - let statResult: Int32 = lstat(expandedPath, &sb) + let statResult: Int32 = lstat(standardizedPath, &sb) guard statResult == 0 else { sendSuccessResponse(fd: fd, data: [ "valid": false, - "errors": ["File not found: \(expandedPath)"], + "errors": ["File not found: \(standardizedPath)"], ] as [String: Any]) return } @@ -490,7 +501,7 @@ public final class StatusSocket: @unchecked Sendable { guard (sb.st_mode & S_IFMT) == S_IFREG else { sendSuccessResponse(fd: fd, data: [ "valid": false, - "errors": ["Not a regular file: \(expandedPath)"], + "errors": ["Not a regular file: \(standardizedPath)"], ] as [String: Any]) return } @@ -505,7 +516,7 @@ public final class StatusSocket: @unchecked Sendable { } do { - let fileData = try Data(contentsOf: URL(fileURLWithPath: expandedPath)) + let fileData = try Data(contentsOf: URL(fileURLWithPath: standardizedPath)) let errors = AutopilotConfigValidator.validate(data: fileData) sendSuccessResponse(fd: fd, data: [ "valid": errors.isEmpty,