macos: handled untrusted OSC8 hyperlinks more carefully (#13634)

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 PR also adds an explicit `link-osc8` config (default true) that
users can use to disable osc8 completely.

## Demos

### Custom URL Schemes (Confirm)

<img width="1432" height="1110" alt="CleanShot 2026-08-05 at 10 25
57@2x"
src="https://github.com/user-attachments/assets/f7773ca2-3389-4749-a5c9-393ae097c044"
/>

### Invisible Characters (Block)

<img width="1432" height="1110" alt="CleanShot 2026-08-05 at 10 26
44@2x"
src="https://github.com/user-attachments/assets/bd2d0f33-f128-46e8-9bdb-227afecbb942"
/>

### Executable Target (Block)

<img width="1432" height="1110" alt="CleanShot 2026-08-05 at 10 27
31@2x"
src="https://github.com/user-attachments/assets/080c0524-2c8e-4931-892f-d2643a5d0d4e"
/>
This commit is contained in:
Mitchell Hashimoto
2026-08-05 10:44:56 -07:00
committed by GitHub
11 changed files with 577 additions and 4 deletions

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