diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..cbb6284 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-04-03 - Path Traversal in Config Validation +**Vulnerability:** Arbitrary file read in daemon UNIX socket via path traversal +**Learning:** `expandingTildeInPath` alone does not sandbox against path traversal sequences (like `../../etc/shadow`). +**Prevention:** Always use `.standardized` to resolve traversal sequences and validate boundaries using `.hasPrefix()` with a trailing slash against the allowed canonical directory. diff --git a/Sources/Cacheout/Headless/StatusSocket.swift b/Sources/Cacheout/Headless/StatusSocket.swift index 12c516e..f9884a6 100644 --- a/Sources/Cacheout/Headless/StatusSocket.swift +++ b/Sources/Cacheout/Headless/StatusSocket.swift @@ -474,14 +474,24 @@ public final class StatusSocket: @unchecked Sendable { } let expandedPath = (path as NSString).expandingTildeInPath + let standardizedPath = URL(fileURLWithPath: expandedPath).standardized.path + let allowedPrefix = NSHomeDirectory().appending("/.cacheout/") + + guard standardizedPath.hasPrefix(allowedPrefix) else { + sendSuccessResponse(fd: fd, data: [ + "valid": false, + "errors": ["Path traversal detected: file must be inside ~/.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 +500,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 +515,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,