diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..bd28a03 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-04-02 - Path Traversal in File Validation +**Vulnerability:** Path traversal vulnerability in `StatusSocket.swift`'s `validate_config` socket command. +**Learning:** `expandingTildeInPath` does not resolve `..` or sandbox the path, allowing a malicious user to access files outside the user's home directory. +**Prevention:** Use `.standardizingPath` after `.expandingTildeInPath`, and enforce directory boundaries by verifying the final path `.hasPrefix()` against a canonical allowed path, such as the user's home directory. diff --git a/Sources/Cacheout/Headless/StatusSocket.swift b/Sources/Cacheout/Headless/StatusSocket.swift index 12c516e..7a506af 100644 --- a/Sources/Cacheout/Headless/StatusSocket.swift +++ b/Sources/Cacheout/Headless/StatusSocket.swift @@ -474,14 +474,26 @@ public final class StatusSocket: @unchecked Sendable { } let expandedPath = (path as NSString).expandingTildeInPath + let standardizedPath = (expandedPath as NSString).standardizingPath + + let allowedPrefix = URL(fileURLWithPath: FileManager.default.homeDirectoryForCurrentUser.path) + .appendingPathComponent(".cacheout").path + "/" + + guard standardizedPath.hasPrefix(allowedPrefix) else { + sendSuccessResponse(fd: fd, data: [ + "valid": false, + "errors": ["Path traversal detected or path outside allowed directory: \(standardizedPath)"], + ] 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 +502,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 }