Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 15 additions & 4 deletions Sources/Cacheout/Headless/StatusSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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,
Expand Down