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
3 changes: 2 additions & 1 deletion Sources/App/BetterShotDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
97 changes: 93 additions & 4 deletions Sources/Services/AppUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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) {
Expand Down
12 changes: 6 additions & 6 deletions bettershot-landing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down