diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5671c701..fec9dc4b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -135,12 +135,18 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? if let parsed = URL(string: trimmed), let scheme = parsed.scheme?.lowercased() { if scheme == "http" || scheme == "https" { + guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else { + return .external(parsed) + } return .embeddedBrowser(parsed) } return .external(parsed) } if let webURL = resolveBrowserNavigableURL(trimmed) { + guard BrowserInsecureHTTPSettings.normalizeHost(webURL.host ?? "") != nil else { + return .external(webURL) + } return .embeddedBrowser(webURL) } @@ -979,6 +985,18 @@ class GhosttyApp { NSWorkspace.shared.open(url) } case let .embeddedBrowser(url): + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { + return performOnMain { + NSWorkspace.shared.open(url) + } + } + + // If a host whitelist is configured and this host isn't in it, open externally. + if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) { + return performOnMain { + NSWorkspace.shared.open(url) + } + } guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id else { return false } return performOnMain { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 96099f0d..873da962 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -68,19 +68,71 @@ enum BrowserLinkOpenSettings { static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true + static let browserHostWhitelistKey = "browserHostWhitelist" + static let defaultBrowserHostWhitelist: String = "" + static func openTerminalLinksInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool { if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) == nil { return defaultOpenTerminalLinksInCmuxBrowser } return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) } + + static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { + let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist + return raw + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + /// Check whether a hostname matches the configured whitelist. + /// Empty whitelist means "allow all" (no filtering). + /// Supports exact match and wildcard prefix (`*.example.com`). + static func hostMatchesWhitelist(_ host: String, defaults: UserDefaults = .standard) -> Bool { + let rawPatterns = hostWhitelist(defaults: defaults) + if rawPatterns.isEmpty { return true } + guard let normalizedHost = BrowserInsecureHTTPSettings.normalizeHost(host) else { return false } + for rawPattern in rawPatterns { + guard let pattern = normalizeWhitelistPattern(rawPattern) else { continue } + if hostMatchesPattern(normalizedHost, pattern: pattern) { + return true + } + } + return false + } + + private static func normalizeWhitelistPattern(_ 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 = BrowserInsecureHTTPSettings.normalizeHost(suffixRaw) else { return nil } + return "*.\(suffix)" + } + + return BrowserInsecureHTTPSettings.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 + } } enum BrowserInsecureHTTPSettings { static let allowlistKey = "browserInsecureHTTPAllowlist" static let defaultAllowlistPatterns = [ - "127.0.0.1", "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", "*.localtest.me", ] static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n") @@ -189,7 +241,15 @@ enum BrowserInsecureHTTPSettings { private static func trimHost(_ raw: String) -> String? { let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: ".")) - return trimmed.isEmpty ? nil : trimmed + guard !trimmed.isEmpty else { return nil } + + // Canonicalize IDN entries (e.g. bücher.example -> xn--bcher-kva.example) + // so user-entered allowlist patterns compare against URL.host consistently. + if let canonicalized = URL(string: "https://\(trimmed)")?.host { + return canonicalized + } + + return trimmed } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 4eaabe4e..24841d43 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -2423,6 +2423,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(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @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 @@ -2615,13 +2616,40 @@ struct SettingsView: View { .controlSize(.small) } + if openTerminalLinksInCmuxBrowser { + SettingsCardDivider() + + VStack(alignment: .leading, spacing: 6) { + SettingsCardRow( + "Hosts to Open in Embedded Browser", + subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux." + ) { + EmptyView() + } + + TextEditor(text: $browserHostWhitelist) + .font(.system(.body, design: .monospaced)) + .frame(minHeight: 60, maxHeight: 120) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .controlBackgroundColor)) + .cornerRadius(6) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + } + SettingsCardDivider() VStack(alignment: .leading, spacing: 8) { - Text("HTTP Host Allowlist") + Text("HTTP Hosts Allowed in Embedded Browser") .font(.system(size: 13, weight: .semibold)) - Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.") + Text("Controls which HTTP (non-HTTPS) hosts can open in cmux without a warning prompt. Defaults include localhost, 127.0.0.1, ::1, 0.0.0.0, and *.localtest.me.") .font(.caption) .foregroundStyle(.secondary) @@ -2641,7 +2669,7 @@ struct SettingsView: View { ViewThatFits(in: .horizontal) { HStack(alignment: .center, spacing: 10) { - Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -2658,7 +2686,7 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 8) { - Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).") + Text("One host or wildcard per line (for example: localhost, 127.0.0.1, ::1, 0.0.0.0, *.localtest.me).") .font(.caption) .foregroundStyle(.secondary) @@ -2835,6 +2863,7 @@ struct SettingsView: View { browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser + browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 2de3d45c..e27d3db3 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2861,4 +2861,108 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { XCTFail("Expected non-web scheme to open externally") } } + + func testResolvesHostlessHTTPSAsExternal() throws { + let target = try XCTUnwrap(resolveTerminalOpenURLTarget("https:///tmp/cmux.txt")) + switch target { + case let .external(url): + XCTAssertEqual(url.scheme, "https") + XCTAssertNil(url.host) + XCTAssertEqual(url.path, "/tmp/cmux.txt") + default: + XCTFail("Expected hostless HTTPS URL to open externally") + } + } +} + +final class BrowserHostWhitelistTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "BrowserHostWhitelistTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName) + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + func testEmptyWhitelistAllowsAll() { + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + } + + func testExactMatch() { + defaults.set("localhost\n127.0.0.1", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testExactMatchIsCaseInsensitive() { + defaults.set("LocalHost", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("LOCALHOST", defaults: defaults)) + } + + func testWildcardSuffix() { + defaults.set("*.localtest.me", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.app.localtest.me", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localtest.me", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testWildcardIsCaseInsensitive() { + defaults.set("*.Example.COM", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("sub.example.com", defaults: defaults)) + } + + func testBlankLinesAndWhitespaceIgnored() { + defaults.set(" localhost \n\n 127.0.0.1 \n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testMixedExactAndWildcard() { + defaults.set("localhost\n127.0.0.1\n*.local.dev", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("127.0.0.1", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("app.local.dev", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("github.com", defaults: defaults)) + } + + func testDefaultWhitelistIsEmpty() { + let patterns = BrowserLinkOpenSettings.hostWhitelist(defaults: defaults) + XCTAssertTrue(patterns.isEmpty) + } + + func testWildcardRequiresDotBoundary() { + defaults.set("*.example.com", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("badexample.com", defaults: defaults)) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com.evil", defaults: defaults)) + } + + func testWhitelistNormalizesSchemesPortsAndTrailingDots() { + defaults.set("https://LOCALHOST:3000/path\n*.Example.COM:443", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("localhost.", defaults: defaults)) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("api.example.com", defaults: defaults)) + } + + func testInvalidWhitelistEntriesDoNotImplicitlyAllowAll() { + defaults.set("http://\n*.\n", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertFalse(BrowserLinkOpenSettings.hostMatchesWhitelist("example.com", defaults: defaults)) + } + + func testUnicodeWhitelistEntryMatchesPunycodeHost() { + defaults.set("b\u{00FC}cher.example", forKey: BrowserLinkOpenSettings.browserHostWhitelistKey) + XCTAssertTrue(BrowserLinkOpenSettings.hostMatchesWhitelist("xn--bcher-kva.example", defaults: defaults)) + } } diff --git a/cmuxTests/UpdatePillReleaseVisibilityTests.swift b/cmuxTests/UpdatePillReleaseVisibilityTests.swift index c782eee9..3186eb6a 100644 --- a/cmuxTests/UpdatePillReleaseVisibilityTests.swift +++ b/cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -102,12 +102,15 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase { func testDefaultAllowlistPatternsArePresent() { XCTAssertEqual( BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: nil), - ["127.0.0.1", "localhost", "*.localtest.me"] + ["localhost", "127.0.0.1", "::1", "0.0.0.0", "*.localtest.me"] ) } func testWildcardAndExactHostMatching() { XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("localhost", rawAllowlist: nil)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("127.0.0.1", rawAllowlist: nil)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("::1", rawAllowlist: nil)) + XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("0.0.0.0", rawAllowlist: nil)) XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("api.localtest.me", rawAllowlist: nil)) XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("neverssl.com", rawAllowlist: nil)) } diff --git a/web/app/docs/configuration/page.tsx b/web/app/docs/configuration/page.tsx index 49bea384..cb1adcf8 100644 --- a/web/app/docs/configuration/page.tsx +++ b/web/app/docs/configuration/page.tsx @@ -108,6 +108,28 @@ working-directory = ~/Projects`} “cmux processes only” mode. +
+ In Settings → Browser, cmux exposes two host lists with + different purposes: +
+example.com,{" "}
+ *.internal.example).
+ localhost,{" "}
+ 127.0.0.1, ::1, 0.0.0.0, and{" "}
+ *.localtest.me.
+