mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
262
macos/Sources/Helpers/UntrustedURL.swift
Normal file
262
macos/Sources/Helpers/UntrustedURL.swift
Normal 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,
|
||||
]
|
||||
}
|
||||
106
macos/Sources/Helpers/UntrustedURLAlert.swift
Normal file
106
macos/Sources/Helpers/UntrustedURLAlert.swift
Normal 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
|
||||
122
macos/Tests/Ghostty/UntrustedURLTests.swift
Normal file
122
macos/Tests/Ghostty/UntrustedURLTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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