fix(macos): harden exec approvals socket path and permissions
This commit is contained in:
@@ -226,6 +226,7 @@ enum ExecApprovalsStore {
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
private static let defaultAutoAllowSkills = false
|
||||
private static let secureStateDirPermissions = 0o700
|
||||
|
||||
static func fileURL() -> URL {
|
||||
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||
@@ -332,6 +333,7 @@ enum ExecApprovalsStore {
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let url = self.fileURL()
|
||||
self.ensureSecureStateDirectory()
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
@@ -343,6 +345,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func ensureFile() -> ExecApprovalsFile {
|
||||
self.ensureSecureStateDirectory()
|
||||
let url = self.fileURL()
|
||||
let existed = FileManager().fileExists(atPath: url.path)
|
||||
let loaded = self.loadFile()
|
||||
@@ -524,6 +527,18 @@ enum ExecApprovalsStore {
|
||||
self.saveFile(file)
|
||||
}
|
||||
|
||||
private static func ensureSecureStateDirectory() {
|
||||
let url = OpenClawPaths.stateDirURL
|
||||
do {
|
||||
try FileManager().createDirectory(at: url, withIntermediateDirectories: true)
|
||||
try FileManager().setAttributes(
|
||||
[.posixPermissions: self.secureStateDirPermissions],
|
||||
ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.warning("exec approvals state dir permission hardening failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func generateToken() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
|
||||
@@ -544,6 +544,106 @@ private enum ExecHostExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketPathKind: Equatable {
|
||||
case missing
|
||||
case directory
|
||||
case socket
|
||||
case symlink
|
||||
case other
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketPathGuardError: LocalizedError {
|
||||
case lstatFailed(path: String, code: Int32)
|
||||
case parentPathInvalid(path: String, kind: ExecApprovalsSocketPathKind)
|
||||
case socketPathInvalid(path: String, kind: ExecApprovalsSocketPathKind)
|
||||
case unlinkFailed(path: String, code: Int32)
|
||||
case createParentDirectoryFailed(path: String, message: String)
|
||||
case setParentDirectoryPermissionsFailed(path: String, message: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .lstatFailed(path, code):
|
||||
"lstat failed for \(path) (errno \(code))"
|
||||
case let .parentPathInvalid(path, kind):
|
||||
"socket parent path invalid (\(kind)) at \(path)"
|
||||
case let .socketPathInvalid(path, kind):
|
||||
"socket path invalid (\(kind)) at \(path)"
|
||||
case let .unlinkFailed(path, code):
|
||||
"unlink failed for \(path) (errno \(code))"
|
||||
case let .createParentDirectoryFailed(path, message):
|
||||
"socket parent directory create failed at \(path): \(message)"
|
||||
case let .setParentDirectoryPermissionsFailed(path, message):
|
||||
"socket parent directory chmod failed at \(path): \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketPathGuard {
|
||||
static let parentDirectoryPermissions = 0o700
|
||||
|
||||
static func pathKind(at path: String) throws -> ExecApprovalsSocketPathKind {
|
||||
var status = stat()
|
||||
let result = lstat(path, &status)
|
||||
if result != 0 {
|
||||
if errno == ENOENT {
|
||||
return .missing
|
||||
}
|
||||
throw ExecApprovalsSocketPathGuardError.lstatFailed(path: path, code: errno)
|
||||
}
|
||||
|
||||
let fileType = status.st_mode & mode_t(S_IFMT)
|
||||
if fileType == mode_t(S_IFDIR) { return .directory }
|
||||
if fileType == mode_t(S_IFSOCK) { return .socket }
|
||||
if fileType == mode_t(S_IFLNK) { return .symlink }
|
||||
return .other
|
||||
}
|
||||
|
||||
static func hardenParentDirectory(for socketPath: String) throws {
|
||||
let parentURL = URL(fileURLWithPath: socketPath).deletingLastPathComponent()
|
||||
let parentPath = parentURL.path
|
||||
|
||||
switch try self.pathKind(at: parentPath) {
|
||||
case .missing, .directory:
|
||||
break
|
||||
case let kind:
|
||||
throw ExecApprovalsSocketPathGuardError.parentPathInvalid(path: parentPath, kind: kind)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager().createDirectory(at: parentURL, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw ExecApprovalsSocketPathGuardError.createParentDirectoryFailed(
|
||||
path: parentPath,
|
||||
message: error.localizedDescription)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager().setAttributes(
|
||||
[.posixPermissions: self.parentDirectoryPermissions],
|
||||
ofItemAtPath: parentPath)
|
||||
} catch {
|
||||
throw ExecApprovalsSocketPathGuardError.setParentDirectoryPermissionsFailed(
|
||||
path: parentPath,
|
||||
message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
static func removeExistingSocket(at socketPath: String) throws {
|
||||
let kind = try self.pathKind(at: socketPath)
|
||||
switch kind {
|
||||
case .missing:
|
||||
return
|
||||
case .socket:
|
||||
break
|
||||
case .directory, .symlink, .other:
|
||||
throw ExecApprovalsSocketPathGuardError.socketPathInvalid(path: socketPath, kind: kind)
|
||||
}
|
||||
if unlink(socketPath) != 0, errno != ENOENT {
|
||||
throw ExecApprovalsSocketPathGuardError.unlinkFailed(path: socketPath, code: errno)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.socket")
|
||||
private let socketPath: String
|
||||
@@ -583,7 +683,11 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
self.socketFD = -1
|
||||
}
|
||||
if !self.socketPath.isEmpty {
|
||||
unlink(self.socketPath)
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
} catch {
|
||||
self.logger.warning("exec approvals socket cleanup failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,7 +722,14 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
self.logger.error("exec approvals socket create failed")
|
||||
return -1
|
||||
}
|
||||
unlink(self.socketPath)
|
||||
do {
|
||||
try ExecApprovalsSocketPathGuard.hardenParentDirectory(for: self.socketPath)
|
||||
try ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
} catch {
|
||||
self.logger.error("exec approvals socket path hardening failed: \(error.localizedDescription, privacy: .public)")
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
@@ -645,12 +756,18 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
if chmod(self.socketPath, 0o600) != 0 {
|
||||
self.logger.error("exec approvals socket chmod failed")
|
||||
close(fd)
|
||||
try? ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
return -1
|
||||
}
|
||||
if listen(fd, 16) != 0 {
|
||||
self.logger.error("exec approvals socket listen failed")
|
||||
close(fd)
|
||||
try? ExecApprovalsSocketPathGuard.removeExistingSocket(at: self.socketPath)
|
||||
return -1
|
||||
}
|
||||
chmod(self.socketPath, 0o600)
|
||||
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
|
||||
return fd
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user