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 @@
## 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.
18 changes: 14 additions & 4 deletions Sources/Cacheout/Headless/StatusSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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,
Expand Down