Merge pull request #203 from cmer/browser-whitelist
Add wildcard hostname allowlist for built-in browser routing
This commit is contained in:
commit
5ae36fcb91
6 changed files with 243 additions and 7 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,28 @@ working-directory = ~/Projects`}</CodeBlock>
|
|||
“cmux processes only” 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue