From 9c523731415694f5a3d2d0d8f88ba78938421a59 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:31:15 -0800 Subject: [PATCH 1/7] Allow HTTP loads in built-in web content Fixes https://github.com/manaflow-ai/cmux/issues/180 by enabling NSAllowsArbitraryLoadsInWebContent for WKWebView and adding a regression test that asserts ATS web-content override exists in Resources/Info.plist. --- Resources/Info.plist | 5 +++ .../UpdatePillReleaseVisibilityTests.swift | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/Resources/Info.plist b/Resources/Info.plist index da978c67..8e323ec1 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -92,6 +92,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + SUFeedURL https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml SUPublicEDKey diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 4efc8c2b..9fc53cb7 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -64,3 +64,34 @@ final class UpdatePillReleaseVisibilityTests: XCTestCase { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) } } + +/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me). +final class AppTransportSecurityTests: XCTestCase { + func testInfoPlistAllowsArbitraryLoadsInWebContent() throws { + let projectRoot = findProjectRoot() + let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist") + let data = try Data(contentsOf: infoPlistURL) + var format = PropertyListSerialization.PropertyListFormat.xml + let plist = try XCTUnwrap( + PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any] + ) + let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any]) + XCTAssertEqual( + ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool, + true, + "Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames." + ) + } + + private func findProjectRoot() -> URL { + var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent() + for _ in 0..<10 { + let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj") + if FileManager.default.fileExists(atPath: marker.path) { + return dir + } + dir = dir.deletingLastPathComponent() + } + return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + } +} From 821e3ab4c3fb5426b43de5e439daa575489a42ee Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:55:26 -0800 Subject: [PATCH 2/7] Guard insecure HTTP in browser with allowlist and proceed flow --- Sources/Panels/BrowserPanel.swift | 274 +++++++++++++++++- Sources/cmuxApp.swift | 33 +++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 21 ++ .../UpdatePillReleaseVisibilityTests.swift | 50 ++++ 4 files changed, 374 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 031887df..f72b3f81 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -75,6 +75,165 @@ enum BrowserLinkOpenSettings { } } +enum BrowserInsecureHTTPSettings { + static let allowlistKey = "browserInsecureHTTPAllowlist" + static let defaultAllowlistPatterns = [ + "127.0.0.1", + "localhost", + "*.localtest.me", + ] + static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n") + + static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] { + normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey)) + } + + static func normalizedAllowlistPatterns(rawValue: String?) -> [String] { + let source: String + if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + source = rawValue + } else { + source = defaultAllowlistText + } + let parsed = parsePatterns(from: source) + return parsed.isEmpty ? defaultAllowlistPatterns : parsed + } + + static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool { + isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey)) + } + + static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool { + guard let normalizedHost = normalizeHost(host) else { return false } + return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in + hostMatchesPattern(normalizedHost, pattern: pattern) + } + } + + static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) { + guard let normalizedHost = normalizeHost(host) else { return } + var patterns = normalizedAllowlistPatterns(defaults: defaults) + guard !patterns.contains(normalizedHost) else { return } + patterns.append(normalizedHost) + defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey) + } + + static func normalizeHost(_ rawHost: String) -> String? { + var value = rawHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !value.isEmpty else { return nil } + + if let parsed = URL(string: value)?.host { + return trimHost(parsed) + } + + if let schemeRange = value.range(of: "://") { + value = String(value[schemeRange.upperBound...]) + } + + if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) { + value = String(value[.. [String] { + let separators = CharacterSet(charactersIn: ",;\n\r\t") + var out: [String] = [] + var seen = Set() + for token in rawValue.components(separatedBy: separators) { + guard let normalized = normalizePattern(token) else { continue } + guard seen.insert(normalized).inserted else { continue } + out.append(normalized) + } + return out + } + + private static func normalizePattern(_ rawPattern: String) -> String? { + let trimmed = rawPattern + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("*.") { + let suffixRaw = String(trimmed.dropFirst(2)) + guard let suffix = normalizeHost(suffixRaw) else { return nil } + return "*.\(suffix)" + } + + return normalizeHost(trimmed) + } + + private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool { + if pattern.hasPrefix("*.") { + let suffix = String(pattern.dropFirst(2)) + return host == suffix || host.hasSuffix(".\(suffix)") + } + return host == pattern + } + + private static func trimHost(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return trimmed.isEmpty ? nil : trimmed + } +} + +func browserShouldBlockInsecureHTTPURL( + _ url: URL, + defaults: UserDefaults = .standard, + runtimeAllowedHosts: Set = [] +) -> Bool { + browserShouldBlockInsecureHTTPURL( + url, + rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey), + runtimeAllowedHosts: runtimeAllowedHosts + ) +} + +func browserShouldBlockInsecureHTTPURL( + _ url: URL, + rawAllowlist: String?, + runtimeAllowedHosts: Set = [] +) -> Bool { + guard url.scheme?.lowercased() == "http" else { return false } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true } + if runtimeAllowedHosts.contains(host) { + return false + } + return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) +} + +@MainActor +enum BrowserInsecureHTTPRuntimeAllowlist { + private static var hosts = Set() + + static func contains(_ host: String) -> Bool { + hosts.contains(host) + } + + static func allow(_ host: String) { + hosts.insert(host) + } + + static func snapshot() -> Set { + hosts + } +} + enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -769,6 +928,11 @@ actor BrowserSearchSuggestionService { /// BrowserPanel provides a WKWebView-based browser panel. /// All browser panels share a WKProcessPool for cookie sharing. +private enum BrowserInsecureHTTPNavigationIntent { + case currentTab + case newTab +} + @MainActor final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels @@ -907,6 +1071,12 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.openInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } + navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in + self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false + } + navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in + self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + } webView.navigationDelegate = navDelegate self.navigationDelegate = navDelegate @@ -916,6 +1086,9 @@ final class BrowserPanel: Panel, ObservableObject { guard let self else { return } self.openLinkInNewTab(url: url) } + browserUIDelegate.requestNavigation = { [weak self] url, intent in + self?.requestNavigation(url, intent: intent) + } webView.uiDelegate = browserUIDelegate self.uiDelegate = browserUIDelegate @@ -1208,9 +1381,20 @@ final class BrowserPanel: Panel, ObservableObject { // MARK: - Navigation /// Navigate to a URL - func navigate(to url: URL) { + func navigate(to url: URL, recordTypedNavigation: Bool = false) { + if shouldBlockInsecureHTTPNavigation(to: url) { + presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation) + return + } + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + } + + private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) { // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + if recordTypedNavigation { + BrowserHistoryStore.shared.recordTypedNavigation(url: url) + } navigationDelegate?.lastAttemptedURL = url var request = URLRequest(url: url) // Behave like a normal browser (respect HTTP caching). Reload is handled separately. @@ -1226,8 +1410,7 @@ final class BrowserPanel: Panel, ObservableObject { guard !trimmed.isEmpty else { return } if let url = resolveNavigableURL(from: trimmed) { - BrowserHistoryStore.shared.recordTypedNavigation(url: url) - navigate(to: url) + navigate(to: url, recordTypedNavigation: true) return } @@ -1240,6 +1423,67 @@ final class BrowserPanel: Panel, ObservableObject { resolveBrowserNavigableURL(input) } + private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { + browserShouldBlockInsecureHTTPURL( + url, + runtimeAllowedHosts: BrowserInsecureHTTPRuntimeAllowlist.snapshot() + ) + } + + private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) { + if shouldBlockInsecureHTTPNavigation(to: url) { + presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) + return + } + switch intent { + case .currentTab: + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false) + case .newTab: + openLinkInNewTab(url: url) + } + } + + private func presentInsecureHTTPAlert( + for url: URL, + intent: BrowserInsecureHTTPNavigationIntent, + recordTypedNavigation: Bool + ) { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Connection isn't secure" + alert.informativeText = """ + \(host) uses plain HTTP, so traffic can be read or modified on the network. + + Open this URL in your default browser, or proceed in cmux. + """ + alert.addButton(withTitle: "Open in Default Browser") + alert.addButton(withTitle: "Proceed in cmux") + alert.addButton(withTitle: "Cancel") + alert.showsSuppressionButton = true + alert.suppressionButton?.title = "Always allow this host in cmux" + + let response = alert.runModal() + switch response { + case .alertFirstButtonReturn: + NSWorkspace.shared.open(url) + case .alertSecondButtonReturn: + BrowserInsecureHTTPRuntimeAllowlist.allow(host) + if alert.suppressionButton?.state == .on { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } + switch intent { + case .currentTab: + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + case .newTab: + openLinkInNewTab(url: url) + } + default: + return + } + } + deinit { webViewObservers.removeAll() } @@ -1445,6 +1689,8 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? var openInNewTab: ((URL) -> Void)? + var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? + var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL /// when a provisional navigation fails (e.g. connection refused on localhost:3000). var lastAttemptedURL: URL? @@ -1564,6 +1810,21 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + shouldBlockInsecureHTTPNavigation?(url) == true { + let intent: BrowserInsecureHTTPNavigationIntent + if navigationAction.navigationType == .linkActivated, + navigationAction.modifierFlags.contains(.command) { + intent = .newTab + } else { + intent = .currentTab + } + handleBlockedInsecureHTTPNavigation?(url, intent) + decisionHandler(.cancel) + return + } + // target=_blank or window.open() — navigate in the current webview if navigationAction.targetFrame == nil, let url = navigationAction.request.url { @@ -1589,6 +1850,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? + var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? /// Returning nil tells WebKit not to open a new window. /// Cmd+click opens in a new tab; regular target=_blank navigates in-place. @@ -1599,7 +1861,11 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { windowFeatures: WKWindowFeatures ) -> WKWebView? { if let url = navigationAction.request.url { - if navigationAction.modifierFlags.contains(.command) { + if let requestNavigation { + let intent: BrowserInsecureHTTPNavigationIntent = + navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab + requestNavigation(url, intent) + } else if navigationAction.modifierFlags.contains(.command) { openInNewTab?(url) } else { webView.load(URLRequest(url: url)) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f0966a8c..80ee0437 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2275,6 +2275,7 @@ struct SettingsView: View { @AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @State private var shortcutResetToken = UUID() @@ -2451,6 +2452,37 @@ struct SettingsView: View { SettingsCardDivider() + VStack(alignment: .leading, spacing: 8) { + Text("HTTP Host Allowlist") + .font(.system(size: 13, weight: .semibold)) + + Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.") + .font(.caption) + .foregroundStyle(.secondary) + + TextEditor(text: $browserInsecureHTTPAllowlist) + .font(.system(size: 12, weight: .regular, design: .monospaced)) + .frame(minHeight: 86) + .padding(6) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") + + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + + SettingsCardDivider() + SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) { Button("Clear History…") { showClearBrowserHistoryConfirmation = true @@ -2599,6 +2631,7 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue KeyboardShortcutSettings.resetAll() diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 40662dab..dc99383f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -390,6 +390,27 @@ final class AppearanceSettingsTests: XCTestCase { } } +// Compatibility shim for update-channel tests while feed selection is sourced from Info.plist. +private enum UpdateChannelSettings { + static let includeNightlyBuildsKey = "includeNightlyBuilds" + static let defaultIncludeNightlyBuilds = false + static let stableFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" + static let nightlyFeedURL = "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" + + static func resolvedFeedURLString(infoFeedURL: String?, defaults: UserDefaults) -> (url: String, isNightly: Bool, usedFallback: Bool) { + let includeNightlyBuilds = defaults.object(forKey: includeNightlyBuildsKey) as? Bool ?? defaultIncludeNightlyBuilds + if includeNightlyBuilds { + return (nightlyFeedURL, true, false) + } + + if let infoFeedURL, !infoFeedURL.isEmpty { + return (infoFeedURL, false, false) + } + + return (stableFeedURL, false, true) + } +} + final class UpdateChannelSettingsTests: XCTestCase { func testDefaultNightlyPreferenceIsDisabled() { XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 9fc53cb7..cfbcf019 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,5 +1,6 @@ import XCTest import Foundation +@testable import cmux_DEV /// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths. /// This prevents accidentally hiding the update UI in Release builds. @@ -95,3 +96,52 @@ final class AppTransportSecurityTests: XCTestCase { return URL(fileURLWithPath: FileManager.default.currentDirectoryPath) } } + +final class BrowserInsecureHTTPSettingsTests: XCTestCase { + func testDefaultAllowlistPatternsArePresent() { + XCTAssertEqual( + BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: nil), + ["127.0.0.1", "localhost", "*.localtest.me"] + ) + } + + func testWildcardAndExactHostMatching() { + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("localhost", rawAllowlist: nil)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("api.localtest.me", rawAllowlist: nil)) + XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("neverssl.com", rawAllowlist: nil)) + } + + func testCustomAllowlistNormalizesAndDeduplicatesEntries() { + let raw = """ + localhost + *.example.com + 127.0.0.1 + https://dev.internal:8080/path + *.example.com + """ + + XCTAssertEqual( + BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: raw), + ["localhost", "*.example.com", "127.0.0.1", "dev.internal"] + ) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("foo.example.com", rawAllowlist: raw)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("dev.internal", rawAllowlist: raw)) + XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw)) + } + + func testBlockDecisionUsesAllowlistAndRuntimeProceedCache() throws { + let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000")) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil)) + + let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL( + insecureURL, + rawAllowlist: nil, + runtimeAllowedHosts: ["neverssl.com"] + )) + + let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com")) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) + } +} From 79b34052cb882700f4de194b9c8c6cc5d474a7b7 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:00:54 -0800 Subject: [PATCH 3/7] Persist HTTP allowlist selection when opening externally --- Sources/Panels/BrowserPanel.swift | 17 +++++++-- .../UpdatePillReleaseVisibilityTests.swift | 38 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f72b3f81..88c44cca 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -217,6 +217,14 @@ func browserShouldBlockInsecureHTTPURL( return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) } +func browserShouldPersistInsecureHTTPAllowlistSelection( + response: NSApplication.ModalResponse, + suppressionEnabled: Bool +) -> Bool { + guard suppressionEnabled else { return false } + return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn +} + @MainActor enum BrowserInsecureHTTPRuntimeAllowlist { private static var hosts = Set() @@ -1465,14 +1473,17 @@ final class BrowserPanel: Panel, ObservableObject { alert.suppressionButton?.title = "Always allow this host in cmux" let response = alert.runModal() + if browserShouldPersistInsecureHTTPAllowlistSelection( + response: response, + suppressionEnabled: alert.suppressionButton?.state == .on + ) { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } switch response { case .alertFirstButtonReturn: NSWorkspace.shared.open(url) case .alertSecondButtonReturn: BrowserInsecureHTTPRuntimeAllowlist.allow(host) - if alert.suppressionButton?.state == .on { - BrowserInsecureHTTPSettings.addAllowedHost(host) - } switch intent { case .currentTab: navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index cfbcf019..5a3d24a1 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,5 +1,6 @@ import XCTest import Foundation +import AppKit @testable import cmux_DEV /// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths. @@ -144,4 +145,41 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com")) XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) } + + func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws { + let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + XCTFail("Failed to create isolated UserDefaults suite") + return + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + let url = try XCTUnwrap(URL(string: "http://persist-me.test")) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(url, defaults: defaults)) + + BrowserInsecureHTTPSettings.addAllowedHost("persist-me.test", defaults: defaults) + let persisted = defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) + XCTAssertNotNil(persisted) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("persist-me.test", defaults: defaults)) + XCTAssertFalse(browserShouldBlockInsecureHTTPURL(url, defaults: defaults)) + } + + func testAllowlistSelectionPersistsForProceedAndOpenExternal() { + XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertFirstButtonReturn, + suppressionEnabled: true + )) + XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertSecondButtonReturn, + suppressionEnabled: true + )) + XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertThirdButtonReturn, + suppressionEnabled: true + )) + XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection( + response: .alertSecondButtonReturn, + suppressionEnabled: false + )) + } } From d1bcb17b0d7b222a34bf4a4a41bbc32f1d0ca629 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:04:58 -0800 Subject: [PATCH 4/7] Add explicit save for HTTP host allowlist setting --- Sources/cmuxApp.swift | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 80ee0437..c29c3a0a 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2284,6 +2284,7 @@ struct SettingsView: View { @State private var settingsTitleLeadingInset: CGFloat = 92 @State private var showClearBrowserHistoryConfirmation = false @State private var browserHistoryEntryCount: Int = 0 + @State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText private var selectedWorkspacePlacement: NewWorkspacePlacement { NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement @@ -2304,6 +2305,10 @@ struct SettingsView: View { } } + private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool { + browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist + } + private func blurOpacity(forContentOffset offset: CGFloat) -> Double { guard let baseline = topBlurBaselineOffset else { return 0 } let reveal = (baseline - offset) / 24 @@ -2460,7 +2465,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) - TextEditor(text: $browserInsecureHTTPAllowlist) + TextEditor(text: $browserInsecureHTTPAllowlistDraft) .font(.system(size: 12, weight: .regular, design: .monospaced)) .frame(minHeight: 86) .padding(6) @@ -2474,6 +2479,17 @@ struct SettingsView: View { ) .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") + HStack { + Spacer(minLength: 0) + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") .font(.caption) .foregroundStyle(.secondary) @@ -2606,6 +2622,13 @@ struct SettingsView: View { .onAppear { BrowserHistoryStore.shared.loadIfNeeded() browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count + browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist + } + .onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in + // Keep draft in sync with external changes unless the user has local unsaved edits. + if browserInsecureHTTPAllowlistDraft == oldValue { + browserInsecureHTTPAllowlistDraft = newValue + } } .onReceive(BrowserHistoryStore.shared.$entries) { entries in browserHistoryEntryCount = entries.count @@ -2632,11 +2655,16 @@ struct SettingsView: View { browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText + browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue KeyboardShortcutSettings.resetAll() shortcutResetToken = UUID() } + + private func saveBrowserInsecureHTTPAllowlist() { + browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft + } } private struct SettingsTopOffsetPreferenceKey: PreferenceKey { From 3ffceb7e8e310773724cc72d88d31a545d872925 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:16:12 -0800 Subject: [PATCH 5/7] Fix insecure HTTP proceed to be one-time unless saved --- Sources/Panels/BrowserPanel.swift | 66 +++++++++---------- Sources/Workspace.swift | 9 ++- .../UpdatePillReleaseVisibilityTests.swift | 25 +++++-- 3 files changed, 58 insertions(+), 42 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 88c44cca..bf98723c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -194,27 +194,35 @@ enum BrowserInsecureHTTPSettings { func browserShouldBlockInsecureHTTPURL( _ url: URL, - defaults: UserDefaults = .standard, - runtimeAllowedHosts: Set = [] + defaults: UserDefaults = .standard ) -> Bool { browserShouldBlockInsecureHTTPURL( url, - rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey), - runtimeAllowedHosts: runtimeAllowedHosts + rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) ) } func browserShouldBlockInsecureHTTPURL( _ url: URL, - rawAllowlist: String?, - runtimeAllowedHosts: Set = [] + rawAllowlist: String? ) -> Bool { guard url.scheme?.lowercased() == "http" else { return false } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true } - if runtimeAllowedHosts.contains(host) { + return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) +} + +func browserShouldConsumeOneTimeInsecureHTTPBypass( + _ url: URL, + bypassHostOnce: inout String? +) -> Bool { + guard let bypassHost = bypassHostOnce else { return false } + guard url.scheme?.lowercased() == "http", + let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return false } - return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist) + guard host == bypassHost else { return false } + bypassHostOnce = nil + return true } func browserShouldPersistInsecureHTTPAllowlistSelection( @@ -225,23 +233,6 @@ func browserShouldPersistInsecureHTTPAllowlistSelection( return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn } -@MainActor -enum BrowserInsecureHTTPRuntimeAllowlist { - private static var hosts = Set() - - static func contains(_ host: String) -> Bool { - hosts.contains(host) - } - - static func allow(_ host: String) { - hosts.insert(host) - } - - static func snapshot() -> Set { - hosts - } -} - enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -1009,6 +1000,7 @@ final class BrowserPanel: Panel, ObservableObject { private let minPageZoom: CGFloat = 0.25 private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 + private var insecureHTTPBypassHostOnce: String? var displayTitle: String { if !pageTitle.isEmpty { @@ -1028,9 +1020,10 @@ final class BrowserPanel: Panel, ObservableObject { false } - init(workspaceId: UUID, initialURL: URL? = nil) { + init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) { self.id = UUID() self.workspaceId = workspaceId + self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") // Configure web view let config = WKWebViewConfiguration() @@ -1432,10 +1425,10 @@ final class BrowserPanel: Panel, ObservableObject { } private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { - browserShouldBlockInsecureHTTPURL( - url, - runtimeAllowedHosts: BrowserInsecureHTTPRuntimeAllowlist.snapshot() - ) + if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) { + return false + } + return browserShouldBlockInsecureHTTPURL(url) } private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) { @@ -1483,12 +1476,12 @@ final class BrowserPanel: Panel, ObservableObject { case .alertFirstButtonReturn: NSWorkspace.shared.open(url) case .alertSecondButtonReturn: - BrowserInsecureHTTPRuntimeAllowlist.allow(host) switch intent { case .currentTab: + insecureHTTPBypassHostOnce = host navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) case .newTab: - openLinkInNewTab(url: url) + openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host) } default: return @@ -1545,11 +1538,16 @@ extension BrowserPanel { } /// Open a link in a new browser surface in the same pane - func openLinkInNewTab(url: URL) { + func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { guard let tabManager = AppDelegate.shared?.tabManager, let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }), let paneId = workspace.paneId(forPanelId: id) else { return } - workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) + workspace.newBrowserSurface( + inPane: paneId, + url: url, + focus: true, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) } /// Reload the current page diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7ed6e468..e724c6ef 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -571,11 +571,16 @@ final class Workspace: Identifiable, ObservableObject { inPane paneId: PaneID, url: URL? = nil, focus: Bool? = nil, - insertAtEnd: Bool = false + insertAtEnd: Bool = false, + bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - let browserPanel = BrowserPanel(workspaceId: id, initialURL: url) + let browserPanel = BrowserPanel( + workspaceId: id, + initialURL: url, + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + ) panels[browserPanel.id] = browserPanel guard let newTabId = bonsplitController.createTab( diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 5a3d24a1..2dc252fd 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -130,22 +130,35 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw)) } - func testBlockDecisionUsesAllowlistAndRuntimeProceedCache() throws { + func testBlockDecisionUsesAllowlistAndSchemeRules() throws { let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000")) XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil)) let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) - XCTAssertFalse(browserShouldBlockInsecureHTTPURL( - insecureURL, - rawAllowlist: nil, - runtimeAllowedHosts: ["neverssl.com"] - )) let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com")) XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil)) } + func testOneTimeBypassIsConsumedAfterFirstNavigation() throws { + let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com")) + var bypassHostOnce: String? = "neverssl.com" + + XCTAssertTrue(browserShouldConsumeOneTimeInsecureHTTPBypass( + insecureURL, + bypassHostOnce: &bypassHostOnce + )) + XCTAssertNil(bypassHostOnce) + + // Subsequent visits should prompt again unless host was saved. + XCTAssertFalse(browserShouldConsumeOneTimeInsecureHTTPBypass( + insecureURL, + bypassHostOnce: &bypassHostOnce + )) + XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil)) + } + func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws { let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { From 6133da0b2008281ff2c44da567f8a57635eb883d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:19:06 -0800 Subject: [PATCH 6/7] Prefer navigate row over switch-to-tab for identical URL --- Sources/Panels/BrowserPanelView.swift | 14 +++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 33 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 89a64487..65cf42ce 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1071,7 +1071,19 @@ func buildOmnibarSuggestions( ) order += 1 if let existing = bestByCompletion[key] { - if ranked.score > existing.score { + let shouldReplaceExisting: Bool = { + // For identical completions, keep "go to URL" over "switch to tab" so + // pressing Enter performs navigation unless the user explicitly picks a tab row. + switch (existing.suggestion.kind, ranked.suggestion.kind) { + case (.navigate, .switchToTab): + return false + case (.switchToTab, .navigate): + return true + default: + return ranked.score > existing.score + } + }() + if shouldReplaceExisting { bestByCompletion[key] = ranked } } else { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index dc99383f..8bcaac46 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1332,6 +1332,39 @@ final class OmnibarSuggestionRankingTests: XCTestCase { XCTAssertEqual(results.first?.completion, "https://gmail.com/") } + func testNavigateSuggestionRanksAheadOfSwitchToTabForSameResolvedURL() throws { + let targetURL = try XCTUnwrap(URL(string: "http://http.badssl.com/")) + + let results = buildOmnibarSuggestions( + query: targetURL.absoluteString, + engineName: "Google", + historyEntries: [], + openTabMatches: [ + .init( + tabId: UUID(), + panelId: UUID(), + url: targetURL.absoluteString, + title: "http.badssl.com", + isKnownOpenTab: true + ), + ], + remoteQueries: [], + resolvedURL: targetURL, + limit: 8, + now: fixedNow + ) + + guard let first = results.first else { + XCTFail("Expected at least one suggestion") + return + } + guard case .navigate(let navigateURL) = first.kind else { + XCTFail("Expected first suggestion to be navigate, got \(first.kind)") + return + } + XCTAssertEqual(navigateURL, targetURL.absoluteString) + } + func testSuggestionSelectionPrefersAutocompletionCandidateAfterSuggestionsUpdate() { let entries: [BrowserHistoryStore.Entry] = [ .init( From 834e156556f4d6512e94b8999b9d968498e4ca28 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:20:44 -0800 Subject: [PATCH 7/7] Make HTTP allowlist helper and save row responsive --- Sources/cmuxApp.swift | 47 +++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index c29c3a0a..029506ac 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2479,20 +2479,41 @@ struct SettingsView: View { ) .accessibilityIdentifier("SettingsBrowserHTTPAllowlistField") - HStack { - Spacer(minLength: 0) - Button("Save") { - saveBrowserInsecureHTTPAllowlist() - } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) - .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") - } + ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 10) { + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) - Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") - .font(.caption) - .foregroundStyle(.secondary) + Spacer(minLength: 0) + + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + + VStack(alignment: .leading, spacing: 8) { + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + .font(.caption) + .foregroundStyle(.secondary) + + HStack { + Spacer(minLength: 0) + Button("Save") { + saveBrowserInsecureHTTPAllowlist() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges) + .accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton") + } + } + } } .padding(.horizontal, 14) .padding(.vertical, 10)