mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-04 05:10:27 +00:00
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.
107 lines
4.0 KiB
Swift
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
|