mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-21 13:08:09 +00:00
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:
@@ -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 });
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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_");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user