Merge pull request #203 from cmer/browser-whitelist

Add wildcard hostname allowlist for built-in browser routing
This commit is contained in:
Lawrence Chen 2026-02-20 20:47:51 -08:00 committed by GitHub
commit 5ae36fcb91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 243 additions and 7 deletions

View file

@ -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 {

View file

@ -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
}
}

View file

@ -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

View file

@ -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))
}
}

View file

@ -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))
}

View file

@ -108,6 +108,28 @@ working-directory = ~/Projects`}</CodeBlock>
&ldquo;cmux processes only&rdquo; mode.
</Callout>
<h3>Browser link behavior</h3>
<p>
In <strong>Settings Browser</strong>, cmux exposes two host lists with
different purposes:
</p>
<ul>
<li>
<strong>Hosts to Open in Embedded Browser</strong> applies to links
clicked from terminal output. Hosts in this list open in cmux; other
hosts open in your default browser. Supports one host or wildcard per
line (for example: <code>example.com</code>,{" "}
<code>*.internal.example</code>).
</li>
<li>
<strong>HTTP Hosts Allowed in Embedded Browser</strong> applies only
to HTTP (non-HTTPS) URLs. Hosts in this list can open in cmux without
a warning prompt. Defaults include <code>localhost</code>,{" "}
<code>127.0.0.1</code>, <code>::1</code>, <code>0.0.0.0</code>, and{" "}
<code>*.localtest.me</code>.
</li>
</ul>
<h2>Example config</h2>
<CodeBlock title="~/.config/ghostty/config" lang="ini">{`# Font
font-family = SF Mono