mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 16:15:13 +00:00
fix: harden macOS bar launch descriptor (#1533)
Hardens the macOS bar launch.json descriptor against untrusted/foreign-owned files and constrains the decoded descriptor; blocks dashboard file writes to the launch descriptor.
This commit is contained in:
1 parent
619723e9b7
commit
10aff10a67
3 files changed
+106
-3
No files matched your search
@@ -1,4 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
#if os(Linux)
|
||||||
|
import Glibc
|
||||||
|
#else
|
||||||
|
import Darwin
|
||||||
|
#endif
|
||||||
import CCSBarCore
|
import CCSBarCore
|
||||||
|
|
||||||
/// Reads `~/.ccs/bar/launch.json` and spawns the CCS bar server detached,
|
/// Reads `~/.ccs/bar/launch.json` and spawns the CCS bar server detached,
|
||||||
@@ -36,10 +41,80 @@ struct BarServerLauncher: Sendable {
|
|||||||
|
|
||||||
private func loadDescriptor() -> BarLaunchDescriptor? {
|
private func loadDescriptor() -> BarLaunchDescriptor? {
|
||||||
let path = BarLaunchDescriptor.defaultPath(home: home)
|
let path = BarLaunchDescriptor.defaultPath(home: home)
|
||||||
guard FileManager.default.fileExists(atPath: path),
|
guard isSafeDescriptorFile(path),
|
||||||
let data = FileManager.default.contents(atPath: path)
|
let data = FileManager.default.contents(atPath: path),
|
||||||
|
let descriptor = try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data),
|
||||||
|
isSafeDescriptor(descriptor)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
return try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data)
|
return descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Treat launch.json as untrusted because it lives under a user-writable CCS
|
||||||
|
/// directory. Refuse symlinks, files not owned by the current user, and files
|
||||||
|
/// writable by group/other before decoding executable details.
|
||||||
|
private func isSafeDescriptorFile(_ path: String) -> Bool {
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: path) else { return false }
|
||||||
|
|
||||||
|
guard let linkValues = try? URL(fileURLWithPath: path).resourceValues(forKeys: [.isSymbolicLinkKey]),
|
||||||
|
linkValues.isSymbolicLink != true,
|
||||||
|
let attrs = try? fm.attributesOfItem(atPath: path),
|
||||||
|
(attrs[.type] as? FileAttributeType) == .typeRegular,
|
||||||
|
let owner = attrs[.ownerAccountID] as? NSNumber,
|
||||||
|
owner.uint32Value == getuid(),
|
||||||
|
let permissions = attrs[.posixPermissions] as? NSNumber
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
// Disallow group/other write bits. User-writable is expected so CCS can refresh it.
|
||||||
|
return (permissions.uint16Value & 0o022) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the descriptor schema and constrain it to the expected CCS bar
|
||||||
|
/// server command shape: runtime absolute path + absolute entry point +
|
||||||
|
/// exactly "bar serve". This blocks shell descriptors such as
|
||||||
|
/// /bin/sh -c attacker-command while preserving the installed launch path.
|
||||||
|
private func isSafeDescriptor(_ descriptor: BarLaunchDescriptor) -> Bool {
|
||||||
|
guard descriptor.schema == 1, descriptor.args.count == 3 else { return false }
|
||||||
|
guard descriptor.args[1] == "bar", descriptor.args[2] == "serve" else { return false }
|
||||||
|
guard isAbsolutePath(descriptor.runtime), isAbsolutePath(descriptor.args[0]) else { return false }
|
||||||
|
guard descriptor.home == home else { return false }
|
||||||
|
if let ccsHome = descriptor.ccsHome, !ccsHome.isEmpty, !isAbsolutePath(ccsHome) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeName = URL(fileURLWithPath: descriptor.runtime).lastPathComponent.lowercased()
|
||||||
|
let allowedRuntimes: Set<String> = ["node", "nodejs", "bun"]
|
||||||
|
guard allowedRuntimes.contains(runtimeName) else { return false }
|
||||||
|
guard FileManager.default.isExecutableFile(atPath: descriptor.runtime) else { return false }
|
||||||
|
|
||||||
|
let entry = descriptor.args[0]
|
||||||
|
let entryName = URL(fileURLWithPath: entry).lastPathComponent.lowercased()
|
||||||
|
guard entryName == "ccs.js" || entryName == "ccs.ts" else { return false }
|
||||||
|
guard isSafeEntrypointFile(entry), !isUnderCcsDir(entry) else { return false }
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isSafeEntrypointFile(_ path: String) -> Bool {
|
||||||
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
|
||||||
|
(attrs[.type] as? FileAttributeType) == .typeRegular,
|
||||||
|
let permissions = attrs[.posixPermissions] as? NSNumber
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
return (permissions.uint16Value & 0o022) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isUnderCcsDir(_ path: String) -> Bool {
|
||||||
|
let ccsPath = URL(fileURLWithPath: home)
|
||||||
|
.appendingPathComponent(".ccs")
|
||||||
|
.standardizedFileURL
|
||||||
|
.path
|
||||||
|
let targetPath = URL(fileURLWithPath: path).standardizedFileURL.path
|
||||||
|
return targetPath == ccsPath || targetPath.hasPrefix(ccsPath + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAbsolutePath(_ path: String) -> Bool {
|
||||||
|
path.hasPrefix("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Spawn from descriptor
|
// MARK: - Spawn from descriptor
|
||||||
|
|||||||
@@ -437,6 +437,18 @@ export function validateFilePath(filePath: string): {
|
|||||||
if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) {
|
if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) {
|
||||||
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// launch.json is an executable descriptor consumed by the native macOS bar.
|
||||||
|
// It must only be written by trusted bar install/launch code paths, not the
|
||||||
|
// generic dashboard file API.
|
||||||
|
if (
|
||||||
|
pathSegments.length === 2 &&
|
||||||
|
pathSegments[0] === 'bar' &&
|
||||||
|
pathSegments[1] === 'launch.json'
|
||||||
|
) {
|
||||||
|
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||||
|
}
|
||||||
|
|
||||||
return { valid: true, readonly: false };
|
return { valid: true, readonly: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,22 @@ describe('validateFilePath', () => {
|
|||||||
expect(result.readonly).toBe(false);
|
expect(result.readonly).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rejects writes to the macOS bar launch descriptor', () => {
|
||||||
|
const filePath = path.join(tempDir, '.ccs', 'bar', 'launch.json');
|
||||||
|
const result = validateFilePath(filePath);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.readonly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still allows other writes inside the bar directory', () => {
|
||||||
|
const filePath = path.join(tempDir, '.ccs', 'bar', 'serve.log');
|
||||||
|
const result = validateFilePath(filePath);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.readonly).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects sibling paths that only share ~/.ccs prefix', () => {
|
test('rejects sibling paths that only share ~/.ccs prefix', () => {
|
||||||
const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml');
|
const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml');
|
||||||
const result = validateFilePath(bypassPath);
|
const result = validateFilePath(bypassPath);
|
||||||
|
|||||||
Reference in new issue
Block a user