diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 0000000..1f4360a --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-04-04 - Fix Path Traversal in Config Validation +**Vulnerability:** User-controlled socket path passed to `expandingTildeInPath` without verifying it remains within the `~/.cacheout/` boundary. +**Learning:** Resolving a path does not natively sandbox it against traversal attacks (e.g., passing `../../etc/passwd`). +**Prevention:** Securely validate user-supplied file paths by resolving them, standardizing, and enforcing directory boundaries using `.hasPrefix()` against a canonical allowed absolute path with a trailing slash. diff --git a/Sources/Cacheout/Headless/StatusSocket.swift b/Sources/Cacheout/Headless/StatusSocket.swift index 12c516e..da8d374 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 = (expandedPath as NSString).standardizingPath + + let allowedPrefix = NSHomeDirectory() + "/.cacheout/" + guard standardizedPath.hasPrefix(allowedPrefix) else { + sendSuccessResponse(fd: fd, data: [ + "valid": false, + "errors": ["Path traversal detected. File must be within \(allowedPrefix)"], + ] 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,