From 10aff10a67fbe7f4a9835aca8e394e3b62ae2a0c Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:24:13 -0400 Subject: [PATCH] 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. --- .../Sources/CCSBarApp/BarServerLauncher.swift | 81 ++++++++++++++++++- src/web-server/routes/route-helpers.ts | 12 +++ tests/unit/web-server/route-helpers.test.ts | 16 ++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift index 45691c59..7d5a7c64 100644 --- a/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift +++ b/macos-bar/Sources/CCSBarApp/BarServerLauncher.swift @@ -1,4 +1,9 @@ import Foundation +#if os(Linux) +import Glibc +#else +import Darwin +#endif import CCSBarCore /// Reads `~/.ccs/bar/launch.json` and spawns the CCS bar server detached, @@ -36,10 +41,80 @@ struct BarServerLauncher: Sendable { private func loadDescriptor() -> BarLaunchDescriptor? { let path = BarLaunchDescriptor.defaultPath(home: home) - guard FileManager.default.fileExists(atPath: path), - let data = FileManager.default.contents(atPath: path) + guard isSafeDescriptorFile(path), + let data = FileManager.default.contents(atPath: path), + let descriptor = try? JSONDecoder().decode(BarLaunchDescriptor.self, from: data), + isSafeDescriptor(descriptor) 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 = ["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 diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 827fffee..b9e29c5d 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -437,6 +437,18 @@ export function validateFilePath(filePath: string): { if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) { 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 }; } diff --git a/tests/unit/web-server/route-helpers.test.ts b/tests/unit/web-server/route-helpers.test.ts index 4a494149..eaab554e 100644 --- a/tests/unit/web-server/route-helpers.test.ts +++ b/tests/unit/web-server/route-helpers.test.ts @@ -42,6 +42,22 @@ describe('validateFilePath', () => { 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', () => { const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml'); const result = validateFilePath(bypassPath);