Files
ghostty/macos/Sources/Helpers/UntrustedURLAlert.swift
Mitchell Hashimoto 77537c8065 macos: handled untrusted OSC8 hyperlinks more carefully
OSC8 hyperlinks previously executed directly via the NSWorkspace opener
so a malicious application can just do whatever it wanted and trick the
user into opening something through Launch Services.

This PR notifies apprt of OSC8 hyperlinks so they can be handled
specially. In this PR, I added macOS-specific handling of OSC8 through a
variety of improvements:

  - Preview text is sanitized, so invisible Unicode characters now show.
  - Questionable-looking URLs require confirmation to open, but a user
    can confirm to open.
  - Very questionable or definitely unsafe URLs are blocked with an 
    alert that only allows the user to copy the link. The alert also
    notifies the user why.
2026-08-05 10:22:28 -07:00

107 lines
4.0 KiB
Swift

#if os(macOS)
import AppKit
/// Presents decisions for untrusted URLs at the AppKit boundary.
enum UntrustedURLAlert {
static func presentConfirmation(for url: URL, displayString: String) {
deferPresentation {
let workspace = NSWorkspace.shared
let handler = workspace.urlForApplication(toOpen: url)
.map { "\u{201c}\($0.deletingPathExtension().lastPathComponent)\u{201d}" }
?? "the default application"
let alert = NSAlert()
alert.alertStyle = .warning
alert.icon = NSImage(named: NSImage.cautionName)
alert.messageText = "Open Link from Terminal Output?"
alert.informativeText = """
This link will open in \(handler). Only continue if you recognize \
and trust the destination.
"""
alert.accessoryView = targetView(displayString)
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: "Open Link")
present(alert) { response in
// Cancel is deliberately the default action.
guard response == .alertSecondButtonReturn else { return }
_ = workspace.open(url)
}
}
}
static func presentBlock(
reason: UntrustedURL.DenialReason,
displayString: String
) {
deferPresentation {
let alert = NSAlert()
alert.alertStyle = .warning
alert.icon = NSImage(named: NSImage.cautionName)
alert.messageText = "Ghostty Blocked This Link"
alert.informativeText = reason.message
alert.accessoryView = targetView(displayString)
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Copy Link")
present(alert) { response in
// Keep blocked targets out of Launch Services. Copying the
// displayed, sanitized value gives the user an explicit path
// forward without adding a one-click policy bypass.
guard response == .alertSecondButtonReturn else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(displayString, forType: .string)
}
}
}
/// The core action callback runs with the renderer mutex held. Queue modal
/// presentation for the next main-loop turn so AppKit cannot reenter a
/// render callback before that mutex is released.
private static func deferPresentation(_ action: @escaping () -> Void) {
DispatchQueue.main.async(execute: action)
}
private static func present(
_ alert: NSAlert,
completion: @escaping (NSApplication.ModalResponse) -> Void
) {
if let window = NSApp.keyWindow {
alert.beginSheetModal(for: window, completionHandler: completion)
} else {
completion(alert.runModal())
}
}
private static func targetView(_ target: String) -> NSView {
let scrollView = NSScrollView(frame: NSRect(
x: 0,
y: 0,
width: 480,
height: 96
))
scrollView.borderType = .bezelBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
let textView = NSTextView(frame: scrollView.contentView.bounds)
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = false
textView.font = .monospacedSystemFont(
ofSize: NSFont.systemFontSize,
weight: .regular
)
textView.textContainerInset = NSSize(width: 6, height: 6)
textView.string = target
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: .greatestFiniteMagnitude
)
scrollView.documentView = textView
return scrollView
}
}
#endif