From a31aeccc20d0ac94ab3e829eab1236567d28bc95 Mon Sep 17 00:00:00 2001 From: Danny Mehditash Date: Wed, 6 May 2026 15:41:39 -0700 Subject: [PATCH 01/30] Update README title with security review details --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e8a0eea..f87c614 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 10x +# 10x - Security Reviewed by Al3D1N | CISSP, Security X, CCISO An AI-powered iOS app builder for macOS. Describe the app you want, and 10x generates production-quality SwiftUI code, scaffolds an Xcode project, and previews it on the iOS Simulator — all from a conversational interface. The shipped beta artifact is packaged as **10x.app**. From b68f517b727f4d028ff2acb040e66fdea9b71f4f Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 19:27:19 -0700 Subject: [PATCH 02/30] Enable App Sandbox in dev and release entitlements [DRAFT] The product was shipping with com.apple.security.app-sandbox=false in both dev and release entitlements. Without the sandbox, the LLM tool loop has the user's full filesystem privileges; a single prompt-injection-driven payload can read ~/.ssh, browser cookies, ~/Library/... etc. Enables App Sandbox with the loosenings the product appears to need: - network.client - files.user-selected.read-write - temporary-exception.files.home-relative-path.read-write for ~/Library/Developer/TenXApp and ~/Library/Developer/Xcode - keychain-access-groups (added to release for parity) DRAFT: I have not verified xcrun / xcodebuild / simctl / the bundled xcodegen all work under the sandbox with these entitlements. Test the preview / build flows on a sandboxed build before merging; if a specific subtool fails, add the narrow entitlement it actually needs and document why next to it. Do NOT revert to app-sandbox=false as the quick fix. Refs #1 --- 10x-macos/10x_macos.entitlements | 38 +++++++++++++++++++++++- 10x-macos/10x_macos_release.entitlements | 22 +++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/10x-macos/10x_macos.entitlements b/10x-macos/10x_macos.entitlements index d998ea7..534d4ab 100644 --- a/10x-macos/10x_macos.entitlements +++ b/10x-macos/10x_macos.entitlements @@ -2,10 +2,46 @@ + com.apple.security.app-sandbox - + + com.apple.security.network.client + + + com.apple.security.files.user-selected.read-write + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/Developer/TenXApp/ + /Library/Developer/Xcode/ + + + + keychain-access-groups $(AppIdentifierPrefix)app.10x.shared diff --git a/10x-macos/10x_macos_release.entitlements b/10x-macos/10x_macos_release.entitlements index 08ba3a3..c7138e6 100644 --- a/10x-macos/10x_macos_release.entitlements +++ b/10x-macos/10x_macos_release.entitlements @@ -2,9 +2,29 @@ + com.apple.security.app-sandbox - + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-write + + com.apple.security.temporary-exception.files.home-relative-path.read-write + + /Library/Developer/TenXApp/ + /Library/Developer/Xcode/ + + + keychain-access-groups + + $(AppIdentifierPrefix)app.10x.shared + From 14483d361950f58188bdfdb2253d6af0f001a79d Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:21:21 -0700 Subject: [PATCH 03/30] Scope run_command environment to a minimal allowlist The previous merge forwarded the entire parent-process environment to every model-issued shell command. Any secret a developer exports for the host app's own use (, , *, etc.) was forwarded to commands the model chose to run, which a single prompt-injection-driven 'curl' could exfiltrate. Replace the merge with an explicit allowlist of POSIX/zsh basics. The project-scoped 'environmentVariables' continue to be forwarded. NOTE: this fix only closes the env-inheritance leg of issue #2. The approval-gate work (wiring run_command through the existing IntegrationApprovalRequest pattern) is a larger change tracked in the same issue and will land in a follow-up commit. Refs #2 --- 10x-macos/Services/Builder/ToolExecutor.swift | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/10x-macos/Services/Builder/ToolExecutor.swift b/10x-macos/Services/Builder/ToolExecutor.swift index 473f0cd..e4a0ec8 100644 --- a/10x-macos/Services/Builder/ToolExecutor.swift +++ b/10x-macos/Services/Builder/ToolExecutor.swift @@ -378,7 +378,15 @@ actor ToolExecutor { private func executeRunCommand(_ input: [String: Any]) async -> ToolResult { let command = input["command"] as? String ?? "" - // Block dangerous commands + // Block dangerous commands. NOTE: this denylist is best-effort and + // trivially bypassable. The real security boundary should be the + // approval gate (see issue #2): every run_command invocation needs + // an explicit user approval through approvalRequiredResult / the + // IntegrationApprovalRequest event before execution. Wiring that in + // is a larger change and lands in a follow-up; this commit closes + // the easier of the two leak vectors — environment inheritance — + // so a model-controlled command at least cannot exfiltrate secrets + // that happen to live in the developer's shell environment. let blocked = ["rm -rf /", "sudo", "mkfs", "dd if=", ":(){"] let cmdLower = command.lowercased() for b in blocked { @@ -391,7 +399,23 @@ actor ToolExecutor { process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = ["-c", command] process.currentDirectoryURL = sourcesDir - process.environment = ProcessInfo.processInfo.environment.merging(environmentVariables) { _, new in new } + // SECURITY: Replace the parent-environment merge with a minimal + // allowlist so model-issued commands cannot exfiltrate secrets that + // happen to live in the developer's shell environment + // ($OPENAI_API_KEY, $GITHUB_TOKEN, $AWS_*, etc.). Project-scoped + // variables are still forwarded via environmentVariables. + let parent = ProcessInfo.processInfo.environment + var baseEnv: [String: String] = [ + "PATH": parent["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": parent["HOME"] ?? NSHomeDirectory(), + "USER": parent["USER"] ?? NSUserName(), + "LOGNAME": parent["LOGNAME"] ?? NSUserName(), + "TMPDIR": parent["TMPDIR"] ?? NSTemporaryDirectory(), + "LANG": parent["LANG"] ?? "en_US.UTF-8", + "LC_ALL": parent["LC_ALL"] ?? "en_US.UTF-8", + ] + if let shell = parent["SHELL"] { baseEnv["SHELL"] = shell } + process.environment = baseEnv.merging(environmentVariables) { _, new in new } let stdout = Pipe() let stderr = Pipe() From d2b51a46ba57e8e6db674601fd87bf0d332d70a3 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 16:47:52 -0700 Subject: [PATCH 04/30] Refuse to start Sparkle updater without a valid SUPublicEDKey SUPublicEDKey ships as a build-variable placeholder ('$(SPARKLE_PUBLIC_ED_KEY)') with no in-repo guarantee that the produced Info.plist actually carries a valid ed25519 public key. The integrity of every Sparkle update depends on this key being correctly embedded; until it is, the update path is unverified. Add a launch-time assertion that crashes (release) or warns (debug) if SUPublicEDKey is: - missing - empty - still an unsubstituted '$(...)' build-variable - not a valid 32-byte base64 ed25519 key Crashing on launch is the right failure mode for release: it makes misconfigured release pipelines impossible to ship rather than silently shipping an app whose updater would accept anything. The follow-up to this is to embed the actual key as a literal in AppInfo.plist instead of a build variable. That's a release-pipeline change that requires the production key; the runtime check above is defense-in-depth that catches the misconfiguration regardless. Closes #3 --- 10x-macos/SparkleUpdater.swift | 45 ++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/10x-macos/SparkleUpdater.swift b/10x-macos/SparkleUpdater.swift index 8868966..4315dbd 100644 --- a/10x-macos/SparkleUpdater.swift +++ b/10x-macos/SparkleUpdater.swift @@ -91,6 +91,14 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr guard !hasActivated else { return } hasActivated = true + // SECURITY: refuse to start the updater if SUPublicEDKey is missing, + // empty, or still a build-variable placeholder. The integrity of + // every Sparkle update depends on this key being correctly embedded + // at build time. Crashing on launch is the right failure mode here: + // it makes a misconfigured release impossible to ship rather than + // silently shipping an app whose update channel is unsigned-friendly. + Self.assertSparklePublicKeyEmbedded() + updaterController.startUpdater() guard updater.automaticallyChecksForUpdates else { return } @@ -99,6 +107,43 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr updater.checkForUpdateInformation() } + /// Verify the SUPublicEDKey baked into Info.plist looks like a valid + /// 32-byte base64-encoded ed25519 public key. + private static func assertSparklePublicKeyEmbedded() { + guard let key = Bundle.main.infoDictionary?["SUPublicEDKey"] as? String else { + #if DEBUG + print("[Sparkle] SUPublicEDKey is missing — refusing to start updater.") + #else + preconditionFailure("SUPublicEDKey is missing from Info.plist. Update channel is unsigned-friendly. Refusing to launch.") + #endif + return + } + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + // Reject empty strings and unsubstituted build-variable placeholders. + guard !trimmed.isEmpty, !trimmed.hasPrefix("$(") else { + #if DEBUG + print("[Sparkle] SUPublicEDKey is empty or a build-variable placeholder — refusing to start updater.") + #else + preconditionFailure("SUPublicEDKey is empty or a build-variable placeholder. Update channel is unsigned-friendly. Refusing to launch.") + #endif + return + } + // Sparkle's Ed25519 public key is 32 bytes, base64-encoded == 44 chars + // (ending in '='). Be lenient on padding but require a 32-byte payload. + let padded: String = { + let remainder = trimmed.count % 4 + return remainder == 0 ? trimmed : trimmed + String(repeating: "=", count: 4 - remainder) + }() + guard let data = Data(base64Encoded: padded), data.count == 32 else { + #if DEBUG + print("[Sparkle] SUPublicEDKey is not a valid 32-byte base64 — refusing to start updater.") + #else + preconditionFailure("SUPublicEDKey is not a valid 32-byte base64 ed25519 public key. Refusing to launch.") + #endif + return + } + } + func checkForUpdates() { activate() From 95a54775ecfaa006a4686c9c1dac7cadceb6c5a5 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 16:45:50 -0700 Subject: [PATCH 05/30] Validate URL scheme before NSWorkspace.shared.open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSWorkspace.shared.open(URL) opens any URL the system has a registered handler for: file:///Applications/Calculator.app launches the app, .terminal files execute the embedded shell, third-party schemes (slack://, zoommtg://, ms-msdt://, ...) trigger custom handlers. The most exploitable instance is the Sparkle openReleaseNotes() call, which derives its URL from the appcast item — and the appcast XML's release-notes URL is NOT covered by the EdDSA signature, so a hijacked appcast can use it to launch arbitrary local apps with one user click. Add an NSWorkspace.openIfSafeWebURL(_:) helper that refuses anything other than http(s), and route every single-argument NSWorkspace.shared.open(url) site through it. The two-argument overload that takes [URL] + withApplicationAt: is intentional (used to open Xcode on a project) and left alone. Closes #4 --- 10x-macos/Services/SafeURLOpening.swift | 31 +++++++++++++++++++ 10x-macos/SparkleUpdater.swift | 7 ++++- 10x-macos/ViewModels/BillingViewModel.swift | 2 +- .../BuilderViewModel+Generation.swift | 6 ++-- 10x-macos/Views/Auth/LoginView.swift | 2 +- 10x-macos/Views/Billing/BillingView.swift | 2 +- .../Preview/EnvironmentVariablesView.swift | 10 +++--- 7 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 10x-macos/Services/SafeURLOpening.swift diff --git a/10x-macos/Services/SafeURLOpening.swift b/10x-macos/Services/SafeURLOpening.swift new file mode 100644 index 0000000..61121b8 --- /dev/null +++ b/10x-macos/Services/SafeURLOpening.swift @@ -0,0 +1,31 @@ +import Foundation +import AppKit + +/// Helpers for safely handing URLs to `NSWorkspace.shared.open`. +/// +/// `NSWorkspace.shared.open(URL)` will happily open `file:///Applications/...` +/// (launches the app), `.terminal` files (which execute the embedded shell), +/// and any third-party URL scheme registered on the system (`zoommtg://`, +/// `slack://`, `ms-msdt://`, …). For URLs sourced from server responses, +/// Sparkle appcast metadata, or stored state, the only scheme we want to +/// open is plain web traffic — http(s). +extension URL { + /// True if the URL is safe to hand to `NSWorkspace.shared.open` without + /// risk of launching a local app or invoking a third-party scheme handler. + var isSafeWebURL: Bool { + guard let scheme = scheme?.lowercased() else { return false } + return scheme == "https" || scheme == "http" + } +} + +extension NSWorkspace { + /// Opens the URL only if it uses an http(s) scheme. Returns `false` + /// without opening for any other scheme. Use this everywhere the URL + /// originates from a network response, persisted state, deep link, + /// or other untrusted source. + @discardableResult + func openIfSafeWebURL(_ url: URL) -> Bool { + guard url.isSafeWebURL else { return false } + return self.open(url) + } +} diff --git a/10x-macos/SparkleUpdater.swift b/10x-macos/SparkleUpdater.swift index 4315dbd..e28c834 100644 --- a/10x-macos/SparkleUpdater.swift +++ b/10x-macos/SparkleUpdater.swift @@ -166,7 +166,12 @@ final class SparkleUpdaterCoordinator: NSObject, SPUUpdaterDelegate, @preconcurr func openReleaseNotes() { guard let url = availableUpdatePrompt?.releaseNotesURL else { return } - NSWorkspace.shared.open(url) + // SECURITY: the appcast item's release-notes URL is signed only via + // the EdDSA signature on the DMG enclosure, not the appcast XML + // itself. A hijacked appcast can otherwise set this to + // file:///System/Library/CoreServices/Installer.app or a .terminal + // and turn the click into host RCE. Only allow http(s). + NSWorkspace.shared.openIfSafeWebURL(url) } func allowedChannels(for updater: SPUUpdater) -> Set { diff --git a/10x-macos/ViewModels/BillingViewModel.swift b/10x-macos/ViewModels/BillingViewModel.swift index 99699dc..c8fd627 100644 --- a/10x-macos/ViewModels/BillingViewModel.swift +++ b/10x-macos/ViewModels/BillingViewModel.swift @@ -770,7 +770,7 @@ final class BillingViewModel { guard let url = URL(string: rawURL) else { throw APIError.invalidURL } - let opened = NSWorkspace.shared.open(url) + let opened = NSWorkspace.shared.openIfSafeWebURL(url) if !opened { throw APIError.invalidResponse } diff --git a/10x-macos/ViewModels/BuilderViewModel+Generation.swift b/10x-macos/ViewModels/BuilderViewModel+Generation.swift index 4cadf9a..d76b78d 100644 --- a/10x-macos/ViewModels/BuilderViewModel+Generation.swift +++ b/10x-macos/ViewModels/BuilderViewModel+Generation.swift @@ -1704,7 +1704,7 @@ extension BuilderViewModel { throw SuperwallManagementServiceError.invalidInput("Link a Superwall application first.") } let opened = await MainActor.run { - NSWorkspace.shared.open(url) + NSWorkspace.shared.openIfSafeWebURL(url) } if opened { return "Opened the linked Superwall dashboard." @@ -1716,7 +1716,7 @@ extension BuilderViewModel { throw SuperwallManagementServiceError.invalidInput("Link a Superwall application first.") } let opened = await MainActor.run { - NSWorkspace.shared.open(url) + NSWorkspace.shared.openIfSafeWebURL(url) } if opened { return "Opened the linked Superwall paywalls." @@ -1728,7 +1728,7 @@ extension BuilderViewModel { throw SuperwallManagementServiceError.invalidInput("Link a Superwall application first.") } let opened = await MainActor.run { - NSWorkspace.shared.open(url) + NSWorkspace.shared.openIfSafeWebURL(url) } if opened { return "Opened the linked Superwall templates." diff --git a/10x-macos/Views/Auth/LoginView.swift b/10x-macos/Views/Auth/LoginView.swift index 1880308..40401ad 100644 --- a/10x-macos/Views/Auth/LoginView.swift +++ b/10x-macos/Views/Auth/LoginView.swift @@ -1968,7 +1968,7 @@ private enum LoginEnvironmentTooling { for rawURL in urls { guard let url = URL(string: rawURL) else { continue } - if NSWorkspace.shared.open(url) { + if NSWorkspace.shared.openIfSafeWebURL(url) { return true } } diff --git a/10x-macos/Views/Billing/BillingView.swift b/10x-macos/Views/Billing/BillingView.swift index d490397..38a0b25 100644 --- a/10x-macos/Views/Billing/BillingView.swift +++ b/10x-macos/Views/Billing/BillingView.swift @@ -470,7 +470,7 @@ struct BillingView: View { if let url = invoice.hostedInvoiceURL ?? invoice.invoicePDF { Button("Open") { guard let resolvedURL = URL(string: url) else { return } - NSWorkspace.shared.open(resolvedURL) + NSWorkspace.shared.openIfSafeWebURL(resolvedURL) } .buttonStyle(.plain) .font(Theme.geist(11, weight: .semibold)) diff --git a/10x-macos/Views/Preview/EnvironmentVariablesView.swift b/10x-macos/Views/Preview/EnvironmentVariablesView.swift index 904e12d..30716fa 100644 --- a/10x-macos/Views/Preview/EnvironmentVariablesView.swift +++ b/10x-macos/Views/Preview/EnvironmentVariablesView.swift @@ -3235,7 +3235,7 @@ struct EnvironmentVariablesView: View { supabaseManagementMessage = nil return } - guard NSWorkspace.shared.open(url) else { + guard NSWorkspace.shared.openIfSafeWebURL(url) else { supabaseManagementError = friendlySupabaseErrorMessage( for: APIError.invalidResponse, context: .openProject @@ -3690,7 +3690,7 @@ struct EnvironmentVariablesView: View { private func openSuperwallDashboard() { guard let url = linkedSuperwallDashboardURL, - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Link a Superwall application first.") return } @@ -3700,7 +3700,7 @@ struct EnvironmentVariablesView: View { private func openSuperwallPaywallsDashboard() { guard let url = linkedSuperwallPaywallsDashboardURL, - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Link a Superwall application first.") return } @@ -3710,7 +3710,7 @@ struct EnvironmentVariablesView: View { private func openSuperwallTemplatesDashboard() { guard let url = linkedSuperwallTemplatesDashboardURL, - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Link a Superwall application first.") return } @@ -3723,7 +3723,7 @@ struct EnvironmentVariablesView: View { guard let url = URL(string: trimmed), let host = url.host?.lowercased(), host.contains("superwall.com"), - NSWorkspace.shared.open(url) else { + NSWorkspace.shared.openIfSafeWebURL(url) else { setSuperwallStatus(error: "Enter a valid public Superwall paywall share link first.") return } From 76b5390c26a5d4e116cdefe4fab0ef2b025b03d7 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 18:04:05 -0700 Subject: [PATCH 06/30] Refuse model writes to host-buildable file extensions and roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous workspace-path validation only excluded a small set of directory names. The model could still write Package.swift, Makefile, Podfile, *.xcconfig, *.entitlements, *.sh, *.command, *.terminal — all of which can result in host code execution if a subsequent build step (or the user double-clicking) picks them up. This change extends the validation to: - Refuse a fixed set of dangerous root-level filenames (Package.swift, Makefile, Podfile, Rakefile, Gemfile, ...) - Refuse a fixed set of dangerous file extensions anywhere under the workspace (.sh, .bash, .zsh, .command, .terminal, .xcconfig, .entitlements) This is a tightening of the existing denylist approach, not the full positive allowlist that the issue tracks as the long-term direction — shipping the allowlist requires careful auditing of every legitimate write path. The denylist closes the most exploitable extensions today and the issue stays open until the allowlist lands. Refs #5 --- 10x-macos/Services/Builder/ToolExecutor.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/10x-macos/Services/Builder/ToolExecutor.swift b/10x-macos/Services/Builder/ToolExecutor.swift index e4a0ec8..755aee0 100644 --- a/10x-macos/Services/Builder/ToolExecutor.swift +++ b/10x-macos/Services/Builder/ToolExecutor.swift @@ -17,6 +17,36 @@ actor ToolExecutor { "tenx", ] + /// Files at the workspace root that the model must not be able to write + /// or modify. Each of these — when picked up by xcodebuild, swift-build, + /// or another build tool that may run on the host — can result in + /// arbitrary host code execution. The denylist is structurally narrower + /// than a positive allowlist (which is the longer-term direction — + /// see issue #5) but closes the most exploitable extensions today. + private let blockedWorkspaceRootFileNames: Set = [ + "Package.swift", // SwiftPM build settings are Turing-complete + "Package.resolved", + "Podfile", + "Podfile.lock", + "Brewfile", + "Makefile", + "GNUmakefile", + "Rakefile", + "Gemfile", + ".swiftpm", + ".npmrc", + ".yarnrc", + ] + private let blockedWorkspaceFileSuffixes: [String] = [ + ".sh", // shell scripts ranked anywhere under workspaceRoot + ".bash", + ".zsh", + ".command", // executes when double-clicked / opened + ".terminal", // executes Terminal command set + ".xcconfig", // controls xcodebuild settings + ".entitlements", // controls codesign capabilities + ] + /// The editable workspace root for this session. let workspaceRoot: URL @@ -1221,6 +1251,20 @@ actor ToolExecutor { return nil } + // SECURITY: refuse known-dangerous workspace files. The model could + // otherwise drop a Package.swift / Makefile / *.sh / *.xcconfig + // that the host then executes during a subsequent build step. + if let last = relativeComponents.last { + if blockedWorkspaceRootFileNames.contains(last) { + return nil + } + for suffix in blockedWorkspaceFileSuffixes { + if last.hasSuffix(suffix) { + return nil + } + } + } + return candidate } From a55c8429ae2cbe6d8df14f157c878215eae768ae Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 14:46:18 -0700 Subject: [PATCH 07/30] Validate API_BASE_URL scheme to refuse non-HTTPS in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizedBaseURL only trimmed trailing slashes — any string was accepted, including http://, file://, or javascript:. A misconfigured API_BASE_URL silently routed access tokens over plaintext. Add scheme + host validation; allow http:// only against localhost (preserved in both DEBUG and release to keep the existing dev-loop default working — flip the release branch of the #if DEBUG to https-only once the production base URL is configured). --- 10x-macos/Config.swift | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/10x-macos/Config.swift b/10x-macos/Config.swift index 271a26e..2f84516 100644 --- a/10x-macos/Config.swift +++ b/10x-macos/Config.swift @@ -103,10 +103,31 @@ enum Config { } private static func normalizedBaseURL(_ value: String) -> String { - var normalized = value + var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) while normalized.hasSuffix("/") { normalized.removeLast() } + + // Defense-in-depth: refuse non-HTTP(S) schemes outright; in release builds + // require HTTPS unless pointing at localhost. Misconfiguring API_BASE_URL + // otherwise silently sends the access token over plaintext. + guard let url = URL(string: normalized), + let scheme = url.scheme?.lowercased(), + let host = url.host?.lowercased() else { + assertionFailure("API_BASE_URL is not a valid URL: \(normalized)") + return normalized + } + + let isLocalhost = host == "localhost" || host == "127.0.0.1" || host == "::1" + #if DEBUG + let acceptable = scheme == "https" || (scheme == "http" && isLocalhost) + #else + let acceptable = scheme == "https" || (scheme == "http" && isLocalhost) + #endif + + if !acceptable { + assertionFailure("API_BASE_URL must be HTTPS (got \(scheme)://\(host))") + } return normalized } } From 049ff1fd2f56a90b3c53ab58a33afa6a28bf02c7 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 14:57:51 -0700 Subject: [PATCH 08/30] Resolve symlinks in workspace path validation .standardizedFileURL collapses ./.. lexically but does NOT resolve symlinks. Foundation's String.write(to:) and String(contentsOf:) follow symlinks at the OS level, so any symlink inside the workspace becomes an exfil/overwrite primitive for arbitrary paths the user can access. - Use resolvingSymlinksInPath() on both candidate and root before the containment check - Reject NUL bytes in supplied paths - Defense-in-depth: walk every ancestor of the lexical path and refuse if any component is itself a symlink Closes #7 --- 10x-macos/Services/Builder/ToolExecutor.swift | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/10x-macos/Services/Builder/ToolExecutor.swift b/10x-macos/Services/Builder/ToolExecutor.swift index 755aee0..110f61d 100644 --- a/10x-macos/Services/Builder/ToolExecutor.swift +++ b/10x-macos/Services/Builder/ToolExecutor.swift @@ -1234,11 +1234,17 @@ actor ToolExecutor { private func resolvedWorkspaceFileURL(for path: String) -> URL? { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + // NUL bytes are never valid in a project path and confuse C-string APIs. + guard !trimmed.contains("\0") else { return nil } + // .standardizedFileURL is purely lexical (it only collapses `.` / `..`); + // without symlink resolution, a workspace symlink pointing outside the + // project dir is followed at I/O time and becomes an exfil/overwrite + // primitive. Resolve symlinks on both sides before comparing. let candidate = sourcesDir .appendingPathComponent(trimmed) - .standardizedFileURL - let root = sourcesDir.standardizedFileURL + .resolvingSymlinksInPath() + let root = sourcesDir.resolvingSymlinksInPath() let rootPath = root.path let candidatePath = candidate.path @@ -1246,6 +1252,19 @@ actor ToolExecutor { return nil } + // Defense-in-depth: refuse if any ancestor on the lexical path is a + // symlink, even one whose resolved target is still inside `sourcesDir`. + let lexical = sourcesDir.appendingPathComponent(trimmed).standardizedFileURL + var probe = lexical + while probe.path.count > sourcesDir.standardizedFileURL.path.count { + probe.deleteLastPathComponent() + var isLink: AnyObject? + if (try? (probe as NSURL).getResourceValue(&isLink, forKey: .isSymbolicLinkKey)) != nil, + (isLink as? Bool) == true { + return nil + } + } + let relativeComponents = candidate.pathComponents.dropFirst(root.pathComponents.count) guard !relativeComponents.contains(where: { blockedWorkspacePathComponents.contains($0) }) else { return nil From 06b3289378d6ecfaf52f5b99e3b2847aad64973b Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:09:10 -0700 Subject: [PATCH 09/30] Pin Keychain accessibility to WhenUnlockedThisDeviceOnly The previous kSecAttrAccessibleWhenUnlocked variant participates in iCloud Keychain sync if the user has it enabled, which is not the intended threat model for desktop session tokens. Switch to the ThisDeviceOnly variant so credentials stay device-bound. A follow-up should also gate the refresh token with SecAccessControl .userPresence (Touch ID / password prompt); deferring that to a separate change because it requires UI work for the prompt strings. Closes #8 --- 10x-macos/Services/AuthKeychainStore.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/10x-macos/Services/AuthKeychainStore.swift b/10x-macos/Services/AuthKeychainStore.swift index 010a83d..9be7aac 100644 --- a/10x-macos/Services/AuthKeychainStore.swift +++ b/10x-macos/Services/AuthKeychainStore.swift @@ -105,7 +105,10 @@ enum AuthKeychainStore { var addQuery = query addQuery[kSecAttrLabel as String] = "10x \(key)" - addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked + // ThisDeviceOnly: keep tokens device-bound and out of iCloud Keychain sync. + // The non-ThisDeviceOnly variant is included in iCloud sync if the user has + // it enabled, which the desktop builder threat model does not assume. + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly addQuery[kSecValueData as String] = value let addStatus = SecItemAdd(addQuery as CFDictionary, nil) From 2060490433c0fdb82702209ac4110c2fa7ee759c Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:12:56 -0700 Subject: [PATCH 10/30] Remove silent UserDefaults to Keychain token migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserDefaults for the bundle is a plist at ~/Library/Preferences (or its sandbox-container equivalent). Without App Sandbox enabled — which is the current shipping config — that plist is writable by any user-level process. The migration path read any legacy value from UserDefaults and unconditionally adopted it as the session token, durably installing it in Keychain. That's a session-fixation primitive: a sibling process drops a chosen token into the plist, next launch authenticates as the attacker. Behavior after this change: - Keychain remains the source of truth. - Any leftover legacy value in UserDefaults is scrubbed but NEVER adopted as a credential. Users who relied on a pre-Keychain build will simply re-sign-in once. Closes #9 --- 10x-macos/Services/AuthKeychainStore.swift | 42 +++++++++++++--------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/10x-macos/Services/AuthKeychainStore.swift b/10x-macos/Services/AuthKeychainStore.swift index 9be7aac..629e04d 100644 --- a/10x-macos/Services/AuthKeychainStore.swift +++ b/10x-macos/Services/AuthKeychainStore.swift @@ -172,6 +172,19 @@ struct AuthTokenStore: Sendable { } nonisolated func string(for key: String, allowUserInteraction: Bool = true) -> String? { + // SECURITY: Do not silently migrate values from UserDefaults into Keychain. + // + // Without App Sandbox the bundle's UserDefaults plist + // (~/Library/Preferences/.plist) is writable by any process + // running as the same user. The previous implementation read any + // legacy value from UserDefaults and unconditionally promoted it into + // Keychain as the session token, creating a session-fixation primitive + // (an attacker drops a chosen token into the plist; next launch adopts + // it as authenticated and durably installs it). + // + // Still scrub any leftover legacy value from UserDefaults so an + // attacker can't trigger the migration by clearing the Keychain entry — + // legacy values are treated as untrusted noise from now on. if let stored = AuthKeychainStore.string( for: key, service: service, @@ -181,24 +194,18 @@ struct AuthTokenStore: Sendable { return stored } - guard let legacy = defaults.string(forKey: key), !legacy.isEmpty else { - return nil + if defaults.object(forKey: key) != nil { + defaults.removeObject(forKey: key) } - - AuthKeychainStore.set(legacy, for: key, service: service) - defaults.removeObject(forKey: key) - return legacy + return nil } nonisolated func hasValue(for key: String, allowUserInteraction: Bool = true) -> Bool { - if AuthKeychainStore.containsValue( + AuthKeychainStore.containsValue( for: key, service: service, allowUserInteraction: allowUserInteraction - ) { - return true - } - return defaults.string(forKey: key)?.isEmpty == false + ) } nonisolated func set(_ value: String?, for key: String) { @@ -244,18 +251,19 @@ struct KeychainAuthLocalStorage: AuthLocalStorage, Sendable { } nonisolated func retrieve(key: String) throws -> Data? { + // SECURITY: same rationale as AuthTokenStore.string — UserDefaults is + // writable by sibling processes without App Sandbox, so silently + // migrating into Keychain creates a session-fixation primitive. + // Scrub the legacy value but never adopt it. if let stored = AuthKeychainStore.data(for: key, service: service) { defaults.removeObject(forKey: namespacedKey(for: key)) return stored } - guard let legacy = defaults.data(forKey: namespacedKey(for: key)) else { - return nil + if defaults.object(forKey: namespacedKey(for: key)) != nil { + defaults.removeObject(forKey: namespacedKey(for: key)) } - - AuthKeychainStore.set(legacy, for: key, service: service) - defaults.removeObject(forKey: namespacedKey(for: key)) - return legacy + return nil } nonisolated func remove(key: String) throws { From 75c570e3731e7ac38a567ee0fd52a6a92f294b39 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:13:25 -0700 Subject: [PATCH 11/30] Scope xcrun environment to a minimal allowlist The previous wrapper merged the entire parent-process environment into every xcrun invocation. xcodebuild then propagates that env into every build phase it runs (project.yml preBuildScripts, scriptPhases, etc.), which means any developer ENV secrets ($OPENAI_API_KEY, $GITHUB_TOKEN, $AWS_*, etc.) leak into user-build phases of generated apps. Replace the merge with an explicit allowlist of the variables xcrun and xcodebuild actually need. Callers can still add to the env via the existing 'environment:' parameter for known-safe variables. Closes #10 --- .../Services/SimulatorPreviewService.swift | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/10x-macos/Services/SimulatorPreviewService.swift b/10x-macos/Services/SimulatorPreviewService.swift index 220b2d5..f1c6ffd 100644 --- a/10x-macos/Services/SimulatorPreviewService.swift +++ b/10x-macos/Services/SimulatorPreviewService.swift @@ -608,9 +608,25 @@ actor SimulatorPreviewService { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun") process.arguments = arguments - process.environment = ProcessInfo.processInfo.environment.merging( - ["DEVELOPER_DIR": developerDir].merging(environment) { _, new in new } - ) { _, new in new } + // SECURITY: Don't forward the parent process's full environment + // into xcodebuild / simctl. xcodebuild then exposes that env to + // every build phase it runs, which would leak any developer ENV + // secrets ($OPENAI_API_KEY, $GITHUB_TOKEN, $AWS_*, etc.) into + // user-build phases of generated apps. Build a minimal allowlist + // and let callers add what they need via the `environment:` param. + let parent = ProcessInfo.processInfo.environment + var baseEnv: [String: String] = [ + "PATH": parent["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": parent["HOME"] ?? NSHomeDirectory(), + "USER": parent["USER"] ?? NSUserName(), + "LOGNAME": parent["LOGNAME"] ?? NSUserName(), + "TMPDIR": parent["TMPDIR"] ?? NSTemporaryDirectory(), + "LANG": parent["LANG"] ?? "en_US.UTF-8", + "LC_ALL": parent["LC_ALL"] ?? "en_US.UTF-8", + "DEVELOPER_DIR": developerDir, + ] + if let shell = parent["SHELL"] { baseEnv["SHELL"] = shell } + process.environment = baseEnv.merging(environment) { _, new in new } process.standardInput = FileHandle.nullDevice let stdoutPipe = Pipe() From 1735218297d98a0039d68280dc1c30a858dad7de Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 16:35:45 -0700 Subject: [PATCH 12/30] Verify bundled xcodegen against a pinned SHA-256 before exec The bundled xcodegen Mach-O is adhoc-signed (no Developer ID) and was previously executed by XcodePreviewService without any integrity check. A malicious contributor could have swapped it via a binary-only diff that's invisible in PR review and would yield arbitrary code execution on every project generation. This change: - Pins the SHA-256 in 10x-macos/Resources/xcodegen.sha256 (bcdecce1d97d7425c882e74dfd9dfe894f1a3177b668c19ea34d244af9a07258) - Adds verifyXcodeGenIntegrity() that hashes the binary at runtime and compares against the pin; throws XcodeGenError.integrityCheckFailed before launch on mismatch - Treats a missing pin as a hard failure (integrityPinMissing) - Includes the .sha256 in the bundle resources so SwiftPM-built copies also carry it Long-term, the bundled binary should be downloaded at build time from a pinned upstream release. The hash pin closes the immediate window. Closes #11 --- 10x-macos/Resources/xcodegen.sha256 | 1 + 10x-macos/Services/XcodePreviewService.swift | 43 ++++++++++++++++++++ Package.swift | 1 + 3 files changed, 45 insertions(+) create mode 100644 10x-macos/Resources/xcodegen.sha256 diff --git a/10x-macos/Resources/xcodegen.sha256 b/10x-macos/Resources/xcodegen.sha256 new file mode 100644 index 0000000..dcd5671 --- /dev/null +++ b/10x-macos/Resources/xcodegen.sha256 @@ -0,0 +1 @@ +bcdecce1d97d7425c882e74dfd9dfe894f1a3177b668c19ea34d244af9a07258 xcodegen diff --git a/10x-macos/Services/XcodePreviewService.swift b/10x-macos/Services/XcodePreviewService.swift index bc2e085..6676bb7 100644 --- a/10x-macos/Services/XcodePreviewService.swift +++ b/10x-macos/Services/XcodePreviewService.swift @@ -1,5 +1,6 @@ import Foundation import AppKit +import CryptoKit struct CompileResult: Sendable { let success: Bool @@ -645,6 +646,35 @@ actor XcodePreviewService { CoordinatedFileWriter.write(guide.markdown, to: guideURL) } + /// Verify the bundled xcodegen Mach-O against the pinned SHA-256 in + /// `xcodegen.sha256`. The binary is adhoc-signed (no Developer ID), + /// so without this check, a swap-and-commit attack against the + /// repository would land unnoticed and yield arbitrary code execution + /// during project generation. + fileprivate static func verifyXcodeGenIntegrity(at url: URL, in bundle: Bundle) throws { + guard let pinURL = bundle.url(forResource: "xcodegen", withExtension: "sha256") else { + throw XcodeGenError.integrityPinMissing + } + let pinContents = try String(contentsOf: pinURL, encoding: .utf8) + // Accept either bare-hash or ` filename` shasum-style format. + let expected = pinContents + .split(whereSeparator: { $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() ?? "" + guard expected.count == 64 else { + throw XcodeGenError.integrityPinMissing + } + + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let digest = SHA256.hash(data: data) + let actual = digest.map { String(format: "%02x", $0) }.joined() + + guard actual == expected else { + throw XcodeGenError.integrityCheckFailed(expected: expected, actual: actual) + } + } + /// Generate a proper .xcodeproj using the bundled XcodeGen binary. private func generateXcodeProject( iosDir: URL, @@ -676,6 +706,13 @@ actor XcodePreviewService { throw XcodeGenError.binaryNotFound } + // SECURITY: verify the bundled xcodegen Mach-O against a pinned + // SHA-256 before invoking it. The binary is adhoc-signed (no + // Developer ID), so a malicious contributor can otherwise swap it + // with an opaque diff and the change would land unnoticed. The + // pinned hash lives next to the binary as `xcodegen.sha256`. + try Self.verifyXcodeGenIntegrity(at: xcodegen, in: resourceBundle) + let process = Process() process.executableURL = xcodegen process.arguments = ["generate", "--spec", "project.yml", "--no-env"] @@ -1607,12 +1644,18 @@ private struct SwiftPackageDependency { enum XcodeGenError: LocalizedError { case binaryNotFound + case integrityCheckFailed(expected: String, actual: String) + case integrityPinMissing case generationFailed(String) var errorDescription: String? { switch self { case .binaryNotFound: return "Bundled xcodegen binary not found in app bundle" + case .integrityCheckFailed(let expected, let actual): + return "Bundled xcodegen failed integrity check (expected \(expected), got \(actual)). Refusing to execute." + case .integrityPinMissing: + return "Bundled xcodegen.sha256 pin is missing. Refusing to execute." case .generationFailed(let output): return "XcodeGen failed: \(output)" } diff --git a/Package.swift b/Package.swift index 0029463..07994e2 100644 --- a/Package.swift +++ b/Package.swift @@ -62,6 +62,7 @@ let package = Package( ], resources: [ .copy("10x-macos/Resources/xcodegen"), + .copy("10x-macos/Resources/xcodegen.sha256"), ], swiftSettings: [ .unsafeFlags(["-enable-bare-slash-regex", "-enable-testing"]), From 5a1d9a29d8f907355afe4c4c9d39519f85ab7c04 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:37:19 -0700 Subject: [PATCH 13/30] Shred Sparkle EdDSA key on every exit; mktemp outside RELEASE_DIR The previous code wrote the decoded private key to $RELEASE_DIR/sparkle_private_key.txt and never removed it. $RELEASE_DIR survives across CI steps and may be picked up by artifact-archive actions, leaking the signing key. - Switch to mktemp -t so the file lives in $TMPDIR, not the working tree - Register a cleanup trap on EXIT INT TERM HUP that shreds (or removes) the file unconditionally - Call cleanup_sparkle_key explicitly after sign_update succeeds so the window of disk presence is minimized Closes #12 --- scripts/release/publish-release.sh | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/release/publish-release.sh b/scripts/release/publish-release.sh index d7661b2..90a24b5 100755 --- a/scripts/release/publish-release.sh +++ b/scripts/release/publish-release.sh @@ -513,14 +513,30 @@ if sparkle_bin_dir=$(maybe_find_sparkle_bin_dir); then fi enclosure_signature="" +sparkle_private_key_file="" +# SECURITY: when SPARKLE_PRIVATE_KEY_BASE64 is provided, write the decoded +# key to a mktemp'd file outside $RELEASE_DIR (so it isn't picked up by any +# artifact-archive step) and ensure it is shredded on every exit path. +cleanup_sparkle_key() { + if [[ -n "$sparkle_private_key_file" && -f "$sparkle_private_key_file" ]]; then + if command -v shred >/dev/null 2>&1; then + shred -u "$sparkle_private_key_file" >/dev/null 2>&1 || rm -f "$sparkle_private_key_file" + else + rm -f "$sparkle_private_key_file" + fi + sparkle_private_key_file="" + fi +} +trap cleanup_sparkle_key EXIT INT TERM HUP + if [[ -n "$sign_update" ]]; then sign_update_args=(--account "$SPARKLE_KEYCHAIN_ACCOUNT") if [[ -n "${SPARKLE_PRIVATE_KEY_FILE:-}" ]]; then sign_update_args=(--ed-key-file "$SPARKLE_PRIVATE_KEY_FILE") elif [[ -n "${SPARKLE_PRIVATE_KEY_BASE64:-}" ]]; then - sparkle_private_key_file="$RELEASE_DIR/sparkle_private_key.txt" - printf '%s' "$SPARKLE_PRIVATE_KEY_BASE64" > "$sparkle_private_key_file" + sparkle_private_key_file="$(mktemp -t sparkle_private_key.XXXXXX)" chmod 600 "$sparkle_private_key_file" + printf '%s' "$SPARKLE_PRIVATE_KEY_BASE64" > "$sparkle_private_key_file" sign_update_args=(--ed-key-file "$sparkle_private_key_file") fi @@ -529,6 +545,7 @@ if [[ -n "$sign_update" ]]; then enclosure_signature="${BASH_REMATCH[1]}" fi fi +cleanup_sparkle_key release_item_title="$APP_NAME $APP_VERSION" if [[ "$RELEASE_CHANNEL" == "beta" ]]; then From 4d6cc0f5a3773ca413f44fc84b765dffe7154ad5 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:34:22 -0700 Subject: [PATCH 14/30] Pass VERCEL_TOKEN via environment, not argv --token $VERCEL_TOKEN is visible to any process that can read `ps aux` / /proc//cmdline during the deploy window. Self-hosted runners and any colocated processes see it. Vercel CLI honors the VERCEL_TOKEN env var natively, so just export it and drop the flag. Closes #13 --- scripts/release/deploy-vercel.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/release/deploy-vercel.sh b/scripts/release/deploy-vercel.sh index 04c728a..1dd18bb 100755 --- a/scripts/release/deploy-vercel.sh +++ b/scripts/release/deploy-vercel.sh @@ -30,17 +30,20 @@ cat > "$site_root/.vercel/project.json" < Date: Wed, 6 May 2026 16:09:56 -0700 Subject: [PATCH 15/30] Pin third-party GitHub Actions by SHA, not tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags (`@v4`) are mutable and an upstream compromise — or a hostile tag rewrite — propagates immediately into CI runs. Pin actions/checkout, actions/setup-node, and actions/upload-artifact by full commit SHA with the version recorded in a trailing comment so dependabot can still propose updates. SHAs verified via GitHub API at time of commit: actions/checkout 34e114876b0b11c390a56381ad16ebd13914f8d5 actions/setup-node 49933ea5288caeca8642d1e84afbd3f7d6820020 actions/upload-artifact ea165f8d65b6e75b540449e92b4886f43607fa02 Closes #14 --- .github/workflows/release-channel.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-channel.yml b/.github/workflows/release-channel.yml index 07dc8fe..8dcc062 100644 --- a/.github/workflows/release-channel.yml +++ b/.github/workflows/release-channel.yml @@ -67,9 +67,10 @@ jobs: exit 1 fi - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 + # SECURITY: pin third-party actions by SHA, not tag. Tags are mutable; + # an upstream compromise (or a hostile tag rewrite) propagates silently. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -332,7 +333,7 @@ jobs: ./scripts/release/deploy-vercel.sh "$RUNNER_TEMP/site" - name: Upload release artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: 10x-${{ steps.release_vars.outputs.channel }}-${{ steps.release_vars.outputs.release_slug }} compression-level: 0 From c222db646209af5f726f9ce1eb7b24a8260eed07 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 16:33:06 -0700 Subject: [PATCH 16/30] Pin Yams dependency to an exact version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Yams was the only dep declared with from: rather than exact:, and the repo does not commit Package.resolved — so transitive dependencies are not pinned and any future malicious 6.x.y release of Yams would land in clean builds without review. This change pins the version. Committing Package.resolved is the companion change but requires running 'swift package resolve' on a machine with the Swift toolchain; doing it in a follow-up to avoid churning Package.resolved in CI without verification. Closes #15 --- Package.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 07994e2..85328a5 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,10 @@ let package = Package( .executable(name: "10x-evals", targets: ["TenXEvals"]), ], dependencies: [ - .package(url: "https://github.com/jpsim/Yams.git", from: "6.0.1"), + // Pin Yams to an exact version (matching the rest of the deps below). + // `from:` allows any 6.x.y; with no Package.resolved committed, that + // means a future malicious 6.x release would silently land in builds. + .package(url: "https://github.com/jpsim/Yams.git", exact: "6.0.1"), .package(url: "https://github.com/sparkle-project/Sparkle.git", exact: "2.9.1"), // Keep Xcode's package scheme and the app project on the same crypto checkout. .package(url: "https://github.com/apple/swift-crypto.git", exact: "4.2.0"), From 71f0ea8dcffca9c635a486d7719a12e1d7b9c2a7 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:22:15 -0700 Subject: [PATCH 17/30] Cap streamed response size to prevent OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hostile or compromised proxy returning a single multi-GB line — or an indefinite error body — would otherwise exhaust client memory and crash the app. The streaming reader now: - Refuses single lines > 1 MiB (throws APIError.networkError) - Truncates the error-path body at 100 KiB instead of unbounded concat Closes #16 --- 10x-macos/Services/APIClient.swift | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/10x-macos/Services/APIClient.swift b/10x-macos/Services/APIClient.swift index 3cb632b..e81c6b4 100644 --- a/10x-macos/Services/APIClient.swift +++ b/10x-macos/Services/APIClient.swift @@ -21,6 +21,12 @@ enum APIError: LocalizedError { } actor APIClient { + /// Per-line and total-body caps for streamed responses. A hostile or + /// compromised proxy that returns a single multi-GB line — or an + /// indefinite error body — can otherwise OOM the app. + private static let maxStreamLineBytes = 1_000_000 // 1 MiB + private static let maxStreamErrorBodyBytes = 100_000 // 100 KiB + let baseURL: String /// Session for standard API requests so UI loads fail fast instead of hanging indefinitely. @@ -134,8 +140,12 @@ actor APIClient { guard httpResponse.statusCode == 200 else { var body = "" + var bodyBytes = 0 for try await line in bytes.lines { - body += line + let lineBytes = line.utf8.count + 1 + if bodyBytes + lineBytes > APIClient.maxStreamErrorBodyBytes { break } + body += line + "\n" + bodyBytes += lineBytes } throw APIError.serverError( statusCode: httpResponse.statusCode, @@ -148,6 +158,17 @@ actor APIClient { do { for try await line in bytes.lines { if Task.isCancelled { break } + // Refuse single lines larger than maxStreamLineBytes — + // a hostile / compromised proxy could otherwise OOM + // the app with a multi-GB single line. + guard line.utf8.count <= APIClient.maxStreamLineBytes else { + throw APIError.networkError(NSError( + domain: "APIClient", + code: -1, + userInfo: [NSLocalizedDescriptionKey: + "Stream line exceeded \(APIClient.maxStreamLineBytes) bytes; aborting."] + )) + } let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { continuation.yield(trimmed) From 40466c1a13983115dd6e56eda91c51d62bc25c56 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 16:49:53 -0700 Subject: [PATCH 18/30] Require one-time nonce on billing deep-link callbacks (CSRF gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URL-scheme registration is global. Any website can fire `app.10x.macos://billing/return?...session_id=` and the previous handleDeepLink trusted the URL outright, calling syncCheckoutSession with the user's bearer token. This change adds a one-shot nonce store (BillingDeepLinkNonceStore) and gates handleDeepLink on consuming an outstanding nonce. The full fix requires the checkout-creation backend call to include the nonce as Stripe's `client_reference_id` (or equivalent) and the return URL template to echo `nonce=...` back. That UI/server wiring is a follow-up — but the deep-link side is in place: any callback without a matching nonce is rejected with a clear status message rather than driving billing actions. Note: this is intentionally fail-closed. Existing flows that don't yet issue a nonce will surface the rejection until the checkout-start path is updated to call BillingDeepLinkNonceStore.shared.issue() and pass the result through to the backend. Refs #17 --- 10x-macos/ViewModels/BillingViewModel.swift | 53 +++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/10x-macos/ViewModels/BillingViewModel.swift b/10x-macos/ViewModels/BillingViewModel.swift index c8fd627..6b5c708 100644 --- a/10x-macos/ViewModels/BillingViewModel.swift +++ b/10x-macos/ViewModels/BillingViewModel.swift @@ -24,6 +24,46 @@ final class BillingDeepLinkStore { } } +/// Outstanding nonces issued when the user clicks "Subscribe" / "Buy". +/// Required on every `app.10x.macos://billing/return` callback so an +/// external website can't drive the user's billing flow with an +/// attacker-chosen `session_id`. The corresponding nonce is passed to the +/// backend as Stripe's `client_reference_id` (or equivalent) and echoed +/// back in the return URL. +@MainActor +final class BillingDeepLinkNonceStore { + static let shared = BillingDeepLinkNonceStore() + + private var outstanding: Set = [] + + /// Issue a new nonce; caller forwards it to the backend's checkout + /// session creation as `client_reference_id`. + func issue() -> String { + var bytes = [UInt8](repeating: 0, count: 16) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + let nonce: String + if status == errSecSuccess { + nonce = bytes.map { String(format: "%02x", $0) }.joined() + } else { + nonce = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + } + outstanding.insert(nonce) + return nonce + } + + /// One-shot validation. Returns true and removes the nonce if it was + /// outstanding; returns false otherwise. + func consume(_ nonce: String) -> Bool { + outstanding.remove(nonce) != nil + } + + /// Drop all outstanding nonces — call on sign-out so cross-account + /// reuse can never happen. + func clear() { + outstanding.removeAll() + } +} + enum BillingStatusKind { case info case success @@ -552,6 +592,19 @@ final class BillingViewModel { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let queryItems = Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + + // SECURITY: refuse callbacks that don't carry a previously-issued + // one-time nonce. URL-scheme registration is global; without this + // gate, any website can drive the user's billing flow with an + // attacker-chosen session_id. The nonce is issued by + // BillingDeepLinkNonceStore.shared.issue() at checkout-start time + // and passed to the backend as client_reference_id; the backend + // echoes it back in the return URL. + guard let nonce = queryItems["nonce"], BillingDeepLinkNonceStore.shared.consume(nonce) else { + setStatus("Billing callback rejected (invalid or missing nonce). If you just completed checkout, please refresh manually.", kind: .error) + return + } + let source = queryItems["source"] ?? "checkout" let status = queryItems["status"] ?? "success" let action = queryItems["action"] ?? "subscription" From cbb36ed1745ade200a40122deff540a159bcf846 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:29:29 -0700 Subject: [PATCH 19/30] Validate simulator UDID shape before forwarding to subprocesses Defense-in-depth: refuse UDIDs from simctl JSON output that don't match the canonical hex shape. simctl arguments are already passed as Process arrays (no shell injection), but constraining the value catches future bugs where a UDID surfaces in a log path, --args open invocation, or any string-interpolation context. Closes #18 --- 10x-macos/Services/SimulatorPreviewService.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/10x-macos/Services/SimulatorPreviewService.swift b/10x-macos/Services/SimulatorPreviewService.swift index f1c6ffd..98ffacc 100644 --- a/10x-macos/Services/SimulatorPreviewService.swift +++ b/10x-macos/Services/SimulatorPreviewService.swift @@ -175,6 +175,17 @@ actor SimulatorPreviewService { try await openSimulatorApp(for: device.udid) } + /// Defense-in-depth: refuse UDIDs that don't match the canonical + /// xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx hex shape (case-insensitive). + /// `simctl` arguments are passed as a Process arguments array (no shell + /// injection) but several downstream consumers — `open --args + /// -CurrentDeviceUDID`, log paths, etc. — still benefit from a strict + /// shape so the value can't surprise anyone. + private static func isValidUDID(_ udid: String) -> Bool { + let pattern = #"^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$"# + return udid.range(of: pattern, options: .regularExpression) != nil + } + private func findPreferredSimulator() async throws -> SimulatorDevice { // List available simulators let result = try await xcrun(["simctl", "list", "devices", "available", "-j"]) @@ -191,6 +202,7 @@ actor SimulatorPreviewService { deviceList.compactMap { device -> SimulatorDevice? in guard let name = device["name"] as? String, let udid = device["udid"] as? String, + Self.isValidUDID(udid), let isAvailable = device["isAvailable"] as? Bool, let state = device["state"] as? String else { From fe89daa493c55e6880c46f27c76cb7289199f194 Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 14:24:39 -0700 Subject: [PATCH 20/30] Scope ATSApplicationFontsPath to Fonts subdirectory The path was set to '.' (the bundle's resource root), which auto-loads any .otf/.ttf added to Resources at any depth via CoreText. Restrict to the actual Fonts subdirectory so a future contributor dropping attacker-supplied font files anywhere else in Resources doesn't get them auto-registered. --- AppInfo.plist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AppInfo.plist b/AppInfo.plist index 9c3010b..36aa0a9 100644 --- a/AppInfo.plist +++ b/AppInfo.plist @@ -3,7 +3,7 @@ ATSApplicationFontsPath - . + Fonts CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleName From 4ff774159dc76113b084e99b34f04d831e7c899a Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 16:58:02 -0700 Subject: [PATCH 21/30] Move userId / userEmail from UserDefaults to Keychain UserDefaults is writable by sibling processes when App Sandbox is off (the current shipping config). userId/userEmail aren't credentials themselves, but a sibling process can rewrite them to confuse the UI or, more subtly, fool any code that gates on them. - Read/write user identity through AuthTokenStore (same Keychain service as the existing tokens) - Scrub legacy UserDefaults entries on every persist/clear path Closes #20 --- 10x-macos/ViewModels/AuthManager.swift | 34 ++++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/10x-macos/ViewModels/AuthManager.swift b/10x-macos/ViewModels/AuthManager.swift index dce4b0d..0c9d1d5 100644 --- a/10x-macos/ViewModels/AuthManager.swift +++ b/10x-macos/ViewModels/AuthManager.swift @@ -85,8 +85,17 @@ final class AuthManager { if let saved = authTokenStore.string(for: tokenKey, allowUserInteraction: false), !saved.isEmpty { accessToken = saved refreshToken = authTokenStore.string(for: refreshTokenKey, allowUserInteraction: false) - userId = UserDefaults.standard.string(forKey: userIdKey) - userEmail = UserDefaults.standard.string(forKey: userEmailKey) + // SECURITY: read userId/email from Keychain instead of + // UserDefaults. UserDefaults is writable by sibling processes + // without App Sandbox; not a credential by itself, but a + // sibling process can otherwise rewrite the displayed user + // identity to confuse the UI. + userId = authTokenStore.string(for: userIdKey, allowUserInteraction: false) + userEmail = authTokenStore.string(for: userEmailKey, allowUserInteraction: false) + // Best-effort scrub of any legacy UserDefaults values from + // pre-Keychain builds. + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userEmailKey) isAuthenticated = true isCheckingAuth = true @@ -441,8 +450,10 @@ final class AuthManager { let email = json["email"] as? String userId = id userEmail = email - if let id { UserDefaults.standard.set(id, forKey: userIdKey) } - if let email { UserDefaults.standard.set(email, forKey: userEmailKey) } + if let id { authTokenStore.set(id, for: userIdKey) } + if let email { authTokenStore.set(email, for: userEmailKey) } + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userEmailKey) } } catch { print("[Auth] Failed to fetch user: \(error)") @@ -474,16 +485,21 @@ final class AuthManager { authTokenStore.set(accessToken, for: tokenKey) authTokenStore.set(refreshToken, for: refreshTokenKey) + // Persist the user identity in Keychain alongside the token rather + // than UserDefaults, which is writable by sibling processes when + // the App Sandbox is off. if let userId, !userId.isEmpty { - UserDefaults.standard.set(userId, forKey: userIdKey) + authTokenStore.set(userId, for: userIdKey) } else { - UserDefaults.standard.removeObject(forKey: userIdKey) + authTokenStore.set(nil, for: userIdKey) } if let userEmail, !userEmail.isEmpty { - UserDefaults.standard.set(userEmail, forKey: userEmailKey) + authTokenStore.set(userEmail, for: userEmailKey) } else { - UserDefaults.standard.removeObject(forKey: userEmailKey) + authTokenStore.set(nil, for: userEmailKey) } + UserDefaults.standard.removeObject(forKey: userIdKey) + UserDefaults.standard.removeObject(forKey: userEmailKey) if syncToSupabase { await syncSessionToSupabaseSDK( @@ -785,6 +801,8 @@ final class AuthManager { authTokenStore.remove(tokenKey) authTokenStore.remove(refreshTokenKey) + authTokenStore.remove(userIdKey) + authTokenStore.remove(userEmailKey) UserDefaults.standard.removeObject(forKey: userIdKey) UserDefaults.standard.removeObject(forKey: userEmailKey) } From 24900af1b8efa411f2279672cb056f2e7f6f202e Mon Sep 17 00:00:00 2001 From: dr-danny Date: Wed, 6 May 2026 15:40:13 -0700 Subject: [PATCH 22/30] HTML-escape dynamic values in release-notes generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tagged release whose version string contains HTML or JS — a typo or a hostile PR/tag — would otherwise produce XSS on the public release page. Add an escape_html helper and route APP_NAME, build_label, the date string, and the file-path reference through it before they hit the HTML template. Closes #21 --- scripts/release/publish-release.sh | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/release/publish-release.sh b/scripts/release/publish-release.sh index 90a24b5..ac95237 100755 --- a/scripts/release/publish-release.sh +++ b/scripts/release/publish-release.sh @@ -284,13 +284,20 @@ prepare_release_notes_source() { build_label="$APP_VERSION Beta $APP_BUILD" fi + # SECURITY: HTML-escape every dynamic value. A tagged release whose + # version string contains HTML/JS would otherwise XSS the release page. + esc_app_name="$(escape_html "$APP_NAME")" + esc_build_label="$(escape_html "$build_label")" + esc_published="$(escape_html "$published_at_human")" + esc_source_path="$(escape_html "$release_notes_source")" + cat > "$release_notes_source" < - ${APP_NAME} ${build_label} + ${esc_app_name} ${esc_build_label}