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.
This commit is contained in:
Mitchell Hashimoto
2026-08-05 09:58:26 -07:00
parent dd035284c2
commit 77537c8065
11 changed files with 577 additions and 4 deletions

View File

@@ -818,6 +818,7 @@ typedef enum {
GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
GHOSTTY_ACTION_OPEN_URL_KIND_HTML,
GHOSTTY_ACTION_OPEN_URL_KIND_OSC8,
} ghostty_action_open_url_kind_e;
// apprt.action.OpenUrl.C

View File

@@ -46,6 +46,7 @@ extension Ghostty.Action {
case unknown
case text
case html
case osc8
init(_ c: ghostty_action_open_url_kind_e) {
switch c {
@@ -53,6 +54,8 @@ extension Ghostty.Action {
self = .text
case GHOSTTY_ACTION_OPEN_URL_KIND_HTML:
self = .html
case GHOSTTY_ACTION_OPEN_URL_KIND_OSC8:
self = .osc8
default:
self = .unknown
}

View File

@@ -3,6 +3,10 @@ import UniformTypeIdentifiers
import UserNotifications
import GhosttyKit
#if os(macOS)
import AppKit
#endif
protocol GhosttyAppDelegate: AnyObject {
#if os(macOS)
/// Called when a callback needs access to a specific surface. This should return nil
@@ -716,6 +720,13 @@ extension Ghostty {
) -> Bool {
let action = Ghostty.Action.OpenURL(c: v)
// OSC 8 targets are producer-controlled terminal output. Keep them
// out of the unrestricted generic opener so unsafe local files and
// deceptive targets cannot reach Launch Services directly.
if action.kind == .osc8 {
return openUntrustedURL(action.url)
}
// If the URL doesn't have a valid scheme we assume its a file path. The URL
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
// them into schema-less URLs, but these won't open properly in text editors.
@@ -745,6 +756,10 @@ extension Ghostty {
case .unknown:
break
case .osc8:
assertionFailure("OSC 8 URLs must use the safe-opening policy")
return true
}
// Open with the default application for the URL
@@ -752,6 +767,30 @@ extension Ghostty {
return true
}
private static func openUntrustedURL(_ value: String) -> Bool {
let target = UntrustedURL(value)
switch target.decision {
case .allow(let url):
_ = NSWorkspace.shared.open(url)
case .confirm(let url):
UntrustedURLAlert.presentConfirmation(
for: url,
displayString: target.displayString
)
case .deny(let reason):
UntrustedURLAlert.presentBlock(
reason: reason,
displayString: target.displayString
)
}
// Always report OSC 8 actions as handled. Returning false would
// cause the core to retry with the unrestricted fallback opener.
return true
}
private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
let undoManager: UndoManager?
switch target.tag {

View File

@@ -6,6 +6,11 @@ struct URLHoverBanner: View {
let padding: CGFloat = 5
let cornerRadius: CGFloat = 9
let url: String
private var displayedURL: String {
UntrustedURL(url).displayString
}
var body: some View {
ZStack {
HStack {
@@ -13,7 +18,7 @@ struct URLHoverBanner: View {
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
Text(verbatim: displayedURL)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius))
@@ -29,7 +34,7 @@ struct URLHoverBanner: View {
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
Text(verbatim: displayedURL)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius))

View File

@@ -0,0 +1,262 @@
import Foundation
import UniformTypeIdentifiers
/// A URL supplied by a source that should not be allowed to dispatch directly
/// to the operating system, such as terminal output.
struct UntrustedURL: Equatable {
enum DenialReason: Equatable {
case malformedURL
case unsafeCharacters
case invalidWebURL
case inaccessibleFile
case unsafeFile
var message: String {
switch self {
case .malformedURL:
"The target is not an absolute URL with a scheme."
case .unsafeCharacters:
"The target contains invisible or line-breaking characters."
case .invalidWebURL:
"The web target does not contain a valid host."
case .inaccessibleFile:
"The local target does not exist or is not a regular file or directory."
case .unsafeFile:
"Opening this local target could execute code."
}
}
}
enum Decision: Equatable {
/// Open schemes with non-executing, well-understood behavior directly.
case allow(URL)
/// Ask before dispatching a custom scheme to its registered handler.
case confirm(URL)
/// Never dispatch malformed targets or executable local files.
case deny(DenialReason)
}
let string: String
init(_ string: String) {
self.string = string
}
var decision: Decision {
guard !string.isEmpty else { return .deny(.malformedURL) }
// Foundation accepts many Unicode control and formatting characters in
// a URL. UI frameworks can render those same characters as line breaks,
// zero-width text, or bidirectional overrides, so reject them before
// parsing changes their representation.
guard !string.unicodeScalars.contains(where: Self.isUnsafeCharacter) else {
return .deny(.unsafeCharacters)
}
// URL(string:) also accepts relative references. An untrusted target
// must include an explicit scheme so it cannot be reinterpreted as a
// local path by a later layer.
guard
let url = URL(string: string),
let scheme = url.scheme?.lowercased(),
!scheme.isEmpty
else {
return .deny(.malformedURL)
}
switch scheme {
case "http", "https":
// Reject values such as "https:relative". They have a scheme, but
// no authority, and different consumers may resolve them against a
// base URL differently.
guard let host = url.host, !host.isEmpty else {
return .deny(.invalidWebURL)
}
return .allow(url)
case "mailto":
// URLComponents places the address portion of a mailto URL in the
// path. Require one so a bare "mailto:" cannot dispatch an empty
// request to the user's mail application.
guard
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
!components.path.isEmpty
else {
return .deny(.malformedURL)
}
return .allow(url)
case "file":
return fileDecision(for: url)
default:
// A custom scheme can invoke any application registered with
// Launch Services. The caller must show the target and handler
// before allowing that dispatch.
return .confirm(url)
}
}
/// A single-line representation of the effective target. Paths are
/// standardized before display so traversal and repeated separators cannot
/// cause the visible and opened targets to differ.
var displayString: String {
let normalized: String
if let url = URL(string: string), url.scheme != nil {
// File URLs are standardized exactly as they are before opening,
// including symlink resolution. Keep non-file URLs byte-for-byte
// equivalent because repeated separators can be meaningful to a
// web or custom-scheme handler.
normalized = url.isFileURL
? url.standardizedFileURL.resolvingSymlinksInPath().path
: string
} else {
// Scheme-less values are never allowed to open, but they still
// appear in the blocked-target UI. Standardizing them prevents
// slash padding and dot traversal from hiding the effective path.
normalized = URL(filePath: string).standardizedFileURL.path
}
// Escaping happens after normalization so any unsafe scalar that
// remains is visible as text and cannot create a second display line.
var result = String()
result.reserveCapacity(normalized.count)
for scalar in normalized.unicodeScalars {
if Self.isUnsafeCharacter(scalar) {
result += "\\u{\(String(scalar.value, radix: 16, uppercase: true))}"
} else {
result.unicodeScalars.append(scalar)
}
}
return result
}
}
private extension UntrustedURL {
func fileDecision(for url: URL) -> Decision {
// Only local file URLs are meaningful here. Queries and fragments do
// not identify part of a filesystem object and may be interpreted
// inconsistently by Launch Services handlers.
guard url.isFileURL, url.query == nil, url.fragment == nil else {
return .deny(.malformedURL)
}
// An empty host and localhost both refer to this machine. Do not allow
// file URLs that name a remote host and could trigger network access.
if let host = url.host,
!host.isEmpty,
host.caseInsensitiveCompare("localhost") != .orderedSame {
return .deny(.malformedURL)
}
// Classify the effective object, not the spelling supplied by terminal
// output. This collapses dot traversal and prevents a harmless-looking
// symlink name from hiding an executable target.
let canonicalURL = url.standardizedFileURL.resolvingSymlinksInPath()
let resourceValues: URLResourceValues
do {
// Reading all relevant resource keys together also proves that the
// canonical target exists and is accessible.
resourceValues = try canonicalURL.resourceValues(forKeys: [
.contentTypeKey,
.isDirectoryKey,
.isExecutableKey,
.isRegularFileKey,
])
} catch {
return .deny(.inaccessibleFile)
}
// Exclude devices, sockets, and other special filesystem objects. A
// directory is safe to reveal in Finder unless its extension or UTI
// identifies it as an application bundle.
guard resourceValues.isDirectory == true || resourceValues.isRegularFile == true else {
return .deny(.inaccessibleFile)
}
guard !Self.isUnsafeFile(canonicalURL, resourceValues: resourceValues) else {
return .deny(.unsafeFile)
}
return .allow(canonicalURL)
}
static func isUnsafeFile(
_ url: URL,
resourceValues: URLResourceValues
) -> Bool {
// Launch Services uses extensions when choosing a handler. Block known
// executable containers even when their POSIX executable bit is clear.
if unsafePathExtensions.contains(url.pathExtension.lowercased()) {
return true
}
// UTIs cover files whose extension is missing or intentionally
// misleading. Use broad system-declared types so subclasses such as
// shell scripts and application bundles are included automatically.
if let contentType = resourceValues.contentType,
unsafeContentTypes.contains(where: { contentType.conforms(to: $0) }) {
return true
}
// Finally, reject any regular file the filesystem marks executable,
// regardless of its name or detected content type.
return resourceValues.isDirectory != true && resourceValues.isExecutable == true
}
static func isUnsafeCharacter(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
// C0/C1 controls include CR, LF, NEL, and other non-printing bytes.
case 0x00...0x1F, 0x7F...0x9F:
return true
// Directional marks and zero-width characters can reorder or conceal
// portions of the target without changing what the handler receives.
case 0x061C, 0x200B...0x200F, 0x202A...0x202E, 0x2066...0x2069:
return true
// Unicode line/paragraph separators create additional visual lines in
// SwiftUI and AppKit text even though OSC accepts their UTF-8 bytes.
case 0x2028...0x2029:
return true
// Word Joiner and BOM are invisible formatting characters that can be
// used as padding or to disguise otherwise identical-looking targets.
case 0x2060, 0xFEFF:
return true
default:
return false
}
}
// Keep the large policy tables after the behavior so the primary type and
// its decision flow remain easy to scan.
static let unsafePathExtensions: Set<String> = [
"action",
"app",
"applescript",
"class",
"command",
"desktop",
"inetloc",
"jar",
"mobileconfig",
"mpkg",
"pkg",
"scpt",
"terminal",
"tool",
"url",
"webloc",
"workflow",
]
static let unsafeContentTypes: [UTType] = [
.application,
.executable,
.script,
]
}

View File

@@ -0,0 +1,106 @@
#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

View File

@@ -0,0 +1,122 @@
import Foundation
import Testing
@testable import Ghostty
@Suite
struct UntrustedURLTests {
@Test(arguments: ["http://example.com", "https://example.com/path", "mailto:user@example.com"])
func allowsSafeSchemes(_ value: String) {
guard case .allow(let url) = UntrustedURL(value).decision else {
Issue.record("expected an allowed URL")
return
}
#expect(url.absoluteString == value)
}
@Test(arguments: ["https:relative", "http:///missing-host"])
func rejectsWebURLsWithoutHosts(_ value: String) {
#expect(UntrustedURL(value).decision == .deny(.invalidWebURL))
}
@Test(arguments: ["/tmp/file.txt", "../file.txt", "payload.command"])
func rejectsSchemeLessTargets(_ value: String) {
#expect(UntrustedURL(value).decision == .deny(.malformedURL))
}
@Test(arguments: ["vscode://file/tmp/example.swift", "ssh://example.com"])
func confirmsCustomSchemes(_ value: String) {
guard case .confirm(let url) = UntrustedURL(value).decision else {
Issue.record("expected a confirmation decision")
return
}
#expect(url.absoluteString == value)
}
@Test(arguments: ["\u{0085}", "\u{2028}", "\u{2029}", "\u{202E}", "\u{2066}"])
func rejectsInvisibleAndLineBreakingCharacters(_ scalar: String) {
let value = "https://example.com/before\(scalar)after"
#expect(UntrustedURL(value).decision == .deny(.unsafeCharacters))
}
@Test
func allowsNonExecutableLocalFiles() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "document.txt")
try "safe".write(to: file, atomically: true, encoding: .utf8)
guard case .allow(let result) = UntrustedURL(file.absoluteString).decision else {
Issue.record("expected a safe local file")
return
}
#expect(result == file.standardizedFileURL.resolvingSymlinksInPath())
}
@Test(arguments: ["payload.command", "payload.tool", "payload.app", "payload.workflow"])
func rejectsDangerousLocalFileExtensions(_ filename: String) throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: filename)
try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8)
#expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile))
}
@Test
func rejectsScriptContentTypes() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "payload.sh")
try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8)
#expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile))
}
@Test
func rejectsExecutableFilesRegardlessOfExtension() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "payload.txt")
try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: file.path
)
#expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile))
}
@Test
func resolvesSymlinksBeforeClassifyingFiles() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let payload = directory.appending(path: "payload.command")
let link = directory.appending(path: "document.txt")
try "#!/bin/sh\n".write(to: payload, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: payload)
#expect(UntrustedURL(link.absoluteString).decision == .deny(.unsafeFile))
}
@Test(arguments: ["\u{0085}", "\u{2028}", "\u{2029}"])
func previewShowsTheEffectiveStandardizedPath(_ separator: String) {
let value = "/tmp/preview\(separator)////../payload.command////"
#expect(UntrustedURL(value).displayString == "/tmp/payload.command")
}
@Test
func previewEscapesBidirectionalControls() {
let value = "https://example.com/a\u{202E}b"
#expect(UntrustedURL(value).displayString == "https://example.com/a\\u{202E}b")
}
private func makeTemporaryDirectory() throws -> URL {
let result = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(
at: result,
withIntermediateDirectories: false
)
return result
}
}

View File

@@ -334,6 +334,7 @@ const DerivedConfig = struct {
title: ?[:0]const u8,
title_report: bool,
links: []DerivedConfig.Link,
link_osc8: bool,
link_previews: configpkg.LinkPreviews,
scroll_to_bottom: configpkg.Config.ScrollToBottom,
notify_on_command_finish: configpkg.Config.NotifyOnCommandFinish,
@@ -413,6 +414,7 @@ const DerivedConfig = struct {
.title = config.title,
.title_report = config.@"title-report",
.links = links,
.link_osc8 = config.@"link-osc8",
.link_previews = config.@"link-previews",
.scroll_to_bottom = config.@"scroll-to-bottom",
.notify_on_command_finish = config.@"notify-on-command-finish",
@@ -4326,7 +4328,9 @@ fn linkAtPos(
const mouse_mods = self.mouseModsWithCapture(self.mouse.mods);
// If we have the proper modifiers set then we can check for OSC8 links.
if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: {
if (self.config.link_osc8 and
mouse_mods.equal(input.ctrlOrSuper(.{})))
hyperlink: {
const rac = mouse_pin.rowAndCell();
const cell = rac.cell;
if (!cell.hyperlink) break :hyperlink;
@@ -4436,7 +4440,7 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
log.warn("failed to get URI for OSC8 hyperlink", .{});
return false;
};
try self.openUrl(.{ .kind = .unknown, .url = uri });
try self.openUrl(.{ .kind = .osc8, .url = uri });
},
}

View File

@@ -941,6 +941,11 @@ pub const OpenUrl = struct {
/// The URL is known to contain HTML content.
html,
/// The URL came from an OSC 8 hyperlink. Application runtimes should
/// treat this as untrusted terminal output and apply a platform-specific
/// safe-opening policy.
osc8,
test "ghostty.h OpenUrl.Kind" {
try lib.checkGhosttyHEnum(Kind, "GHOSTTY_ACTION_OPEN_URL_KIND_");
}

View File

@@ -1469,6 +1469,14 @@ link: RepeatableLink = .{},
/// `link`). If you want to customize URL matching, use `link` and disable this.
@"link-url": bool = true,
/// Enable hyperlinks created with the OSC 8 escape sequence. When disabled,
/// OSC 8 hyperlinks are not highlighted, previewed, copied, or opened.
///
/// This does not affect URL matching controlled by `link-url`.
///
/// Available since: 1.4.0
@"link-osc8": bool = true,
/// Show link previews for a matched URL.
///
/// When true, link previews are shown for all matched URLs. When false, link

View File

@@ -19,12 +19,21 @@ pub fn open(
kind: apprt.action.OpenUrl.Kind,
url: []const u8,
) !void {
// On macOS, the apprt handles OSC 8 targets before this fallback. Ghostty's
// native apprt applies its allowlist, confirmation, and file safety policy.
// If a macOS embedder declines the action, fail closed rather than bypassing
// that policy by handing producer-controlled terminal output to `open`.
if (comptime builtin.os.tag == .macos) {
if (kind == .osc8) return error.UnsafeOSC8Link;
}
var spawn_opts: std.process.SpawnOptions = switch (builtin.os.tag) {
.linux, .freebsd => .{ .argv = &.{ "xdg-open", url } },
.windows => .{ .argv = &.{ "rundll32", "url.dll,FileProtocolHandler", url } },
.macos => switch (kind) {
.text => .{ .argv = &.{ "open", "-t", url } },
.html, .unknown => .{ .argv = &.{ "open", url } },
.osc8 => unreachable,
},
.ios => return error.Unimplemented,
else => @compileError("unsupported OS"),
@@ -58,6 +67,15 @@ pub fn open(
thread.detach();
}
test "macOS OSC 8 links have no generic opener fallback" {
if (builtin.os.tag != .macos) return error.SkipZigTest;
try std.testing.expectError(
error.UnsafeOSC8Link,
open(.osc8, "file:///tmp/payload.command"),
);
}
fn openThread(io: std.Io, exe_: std.process.Child) void {
// Copy the exe so it is non-const. This is necessary because wait()
// requires a mutable reference and we can't have one as a thread