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/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 031887df..bf98723c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -75,6 +75,164 @@ 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 +) -> Bool { + browserShouldBlockInsecureHTTPURL( + url, + rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey) + ) +} + +func browserShouldBlockInsecureHTTPURL( + _ url: URL, + rawAllowlist: String? +) -> Bool { + guard url.scheme?.lowercased() == "http" else { return false } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true } + 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 + } + guard host == bypassHost else { return false } + bypassHostOnce = nil + return true +} + +func browserShouldPersistInsecureHTTPAllowlistSelection( + response: NSApplication.ModalResponse, + suppressionEnabled: Bool +) -> Bool { + guard suppressionEnabled else { return false } + return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn +} + 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 +927,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 @@ -837,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 { @@ -856,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() @@ -907,6 +1072,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 +1087,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 +1382,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 +1411,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 +1424,70 @@ final class BrowserPanel: Panel, ObservableObject { resolveBrowserNavigableURL(input) } + private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool { + if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) { + return false + } + return browserShouldBlockInsecureHTTPURL(url) + } + + 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() + if browserShouldPersistInsecureHTTPAllowlistSelection( + response: response, + suppressionEnabled: alert.suppressionButton?.state == .on + ) { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } + switch response { + case .alertFirstButtonReturn: + NSWorkspace.shared.open(url) + case .alertSecondButtonReturn: + switch intent { + case .currentTab: + insecureHTTPBypassHostOnce = host + navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation) + case .newTab: + openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host) + } + default: + return + } + } + deinit { webViewObservers.removeAll() } @@ -1290,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 @@ -1445,6 +1698,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 +1819,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 +1859,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 +1870,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/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index aa8a4b0f..9e397788 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/Sources/Workspace.swift b/Sources/Workspace.swift index 71d28ded..29348c39 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -587,11 +587,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/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index daf9ecd6..fd7d23b0 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2306,6 +2306,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() @@ -2314,6 +2315,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 @@ -2334,6 +2336,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 @@ -2482,6 +2488,69 @@ 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: $browserInsecureHTTPAllowlistDraft) + .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") + + 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) + + 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) + + SettingsCardDivider() + SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) { Button("Clear History…") { showClearBrowserHistoryConfirmation = true @@ -2605,6 +2674,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 @@ -2630,11 +2706,17 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue 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 { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 07d399c6..d17a5f97 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1425,6 +1425,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( diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index 4efc8c2b..2dc252fd 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -1,5 +1,7 @@ import XCTest import Foundation +import AppKit +@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. @@ -64,3 +66,133 @@ 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) + } +} + +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 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)) + + 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 { + 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 + )) + } +}