diff --git a/Sources/App/BetterShotDelegate.swift b/Sources/App/BetterShotDelegate.swift index 098be87..61b3ca8 100644 --- a/Sources/App/BetterShotDelegate.swift +++ b/Sources/App/BetterShotDelegate.swift @@ -65,7 +65,8 @@ final class BetterShotDelegate: NSObject, NSApplicationDelegate { if alert.runModal() == .alertFirstButtonReturn { let task = Process() task.launchPath = "/bin/sh" - task.arguments = ["-c", "sleep 0.5; open \"\(Bundle.main.bundlePath)\""] + // Path passed as $0, not interpolated — immune to shell metacharacters. + task.arguments = ["-c", "sleep 0.5; open \"$0\"", Bundle.main.bundlePath] try? task.run() NSApp.terminate(nil) } diff --git a/Sources/Services/AppUpdater.swift b/Sources/Services/AppUpdater.swift index c6970ad..418e139 100644 --- a/Sources/Services/AppUpdater.swift +++ b/Sources/Services/AppUpdater.swift @@ -57,7 +57,8 @@ final class AppUpdater { ?? assets.first { ($0["name"] as? String)?.hasSuffix(".dmg") == true } if let assetURLString = dmgAsset?["browser_download_url"] as? String, - let assetURL = URL(string: assetURLString) { + let assetURL = URL(string: assetURLString), + isTrustedAssetURL(assetURL) { state = .available(version: latestVersion, url: assetURL) ToastWindow.shared.show( title: "Update Available", @@ -115,10 +116,11 @@ final class AppUpdater { ?? assets.first { ($0["name"] as? String)?.hasSuffix(".dmg") == true } if let assetURLString = dmgAsset?["browser_download_url"] as? String, - let assetURL = URL(string: assetURLString) { + let assetURL = URL(string: assetURLString), + isTrustedAssetURL(assetURL) { state = .available(version: latestVersion, url: assetURL) } else { - state = .failed("No .dmg asset found in latest release") + state = .failed("No trusted .dmg asset found in latest release") } } else { state = .upToDate @@ -193,6 +195,14 @@ final class AppUpdater { return } + // Security: refuse to install anything that isn't validly signed by the + // same team as the running app. Without this, a compromised release asset + // is silent arbitrary code execution. + if let verifyError = await Self.verifyUpdateBundle(appBundle, against: currentAppURL) { + state = .failed("Update rejected: \(verifyError)") + return + } + let backupURL = currentAppURL.deletingLastPathComponent() .appendingPathComponent(currentAppURL.lastPathComponent + ".backup") @@ -265,10 +275,89 @@ final class AppUpdater { } } + /// Only accept release assets hosted on this repo's GitHub releases page. + private func isTrustedAssetURL(_ url: URL) -> Bool { + url.scheme == "https" + && url.host == "github.com" + && url.path.hasPrefix("/\(owner)/\(repo)/releases/download/") + } + + /// Verifies the candidate bundle has a valid code signature and the same + /// Team ID as the running app. Returns an error message, or nil if OK. + private static func verifyUpdateBundle(_ candidate: URL, against currentApp: URL) async -> String? { + guard let currentTeam = await teamIdentifier(of: currentApp) else { + return "current app is unsigned; cannot establish a trust anchor for updates" + } + let verify = await runProcess("/usr/bin/codesign", ["--verify", "--deep", "--strict", candidate.path]) + guard verify == 0 else { + return "downloaded app failed code signature verification" + } + guard let candidateTeam = await teamIdentifier(of: candidate) else { + return "downloaded app has no Team ID" + } + guard candidateTeam == currentTeam else { + return "downloaded app is signed by a different team (\(candidateTeam))" + } + return nil + } + + /// Parses `TeamIdentifier=` from `codesign -dv` output; nil if unsigned or "not set". + private static func teamIdentifier(of bundle: URL) async -> String? { + let output = await runProcessCapturingOutput("/usr/bin/codesign", ["-dv", bundle.path]) + guard let line = output?.split(separator: "\n").first(where: { $0.hasPrefix("TeamIdentifier=") }) else { + return nil + } + let team = String(line.dropFirst("TeamIdentifier=".count)).trimmingCharacters(in: .whitespaces) + return (team.isEmpty || team == "not set") ? nil : team + } + + private static func runProcess(_ path: String, _ arguments: [String]) async -> Int32 { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + continuation.resume(returning: process.terminationStatus) + } catch { + continuation.resume(returning: -1) + } + } + } + } + + private static func runProcessCapturingOutput(_ path: String, _ arguments: [String]) async -> String? { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + let pipe = Pipe() + // codesign -dv writes details to stderr + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + } catch { + continuation.resume(returning: nil) + return + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + continuation.resume(returning: String(data: data, encoding: .utf8)) + } + } + } + private func relaunchApp(at appURL: URL) { let task = Process() task.executableURL = URL(fileURLWithPath: "/bin/sh") - task.arguments = ["-c", "sleep 1 && open \"\(appURL.path)\""] + // Path passed as $0, not interpolated — immune to shell metacharacters. + task.arguments = ["-c", "sleep 1 && open \"$0\"", appURL.path] try? task.run() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { diff --git a/bettershot-landing/package.json b/bettershot-landing/package.json index e1004a7..25adda7 100644 --- a/bettershot-landing/package.json +++ b/bettershot-landing/package.json @@ -9,7 +9,7 @@ "start": "next start" }, "dependencies": { - "@emotion/is-prop-valid": "latest", + "@emotion/is-prop-valid": "^1.4.0", "@hookform/resolvers": "^3.10.0", "@radix-ui/react-accordion": "1.2.2", "@radix-ui/react-alert-dialog": "1.1.4", @@ -43,16 +43,16 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", - "cobe": "latest", + "cobe": "^0.6.5", "date-fns": "4.1.0", "embla-carousel-react": "8.5.1", - "framer-motion": "latest", - "geist": "latest", + "framer-motion": "^12.25.0", + "geist": "^1.5.1", "input-otp": "1.4.1", "lucide-react": "^0.454.0", - "motion": "latest", + "motion": "^12.25.0", "next": "15.4.10", - "next-themes": "latest", + "next-themes": "^0.4.6", "react": "^19", "react-day-picker": "9.8.0", "react-dom": "^19",