ReviewOS

also looking at this

craft-native/craft

fix(webview): recover from renderer crashes, and stop dropping window.open()

#72
Merged glennmichael123 wants to merge fix/webview-delegates into main
4 files +729 -23
packages/zig/src/external_link.zigadded+146-0
Changes to packages/zig/src/external_link.zig
@@ -0,0 +1,146 @@
1//! Where a link that wants a new window is allowed to go.
2//!
3//! `window.open()` and `target="_blank"` ask WKWebView for a second webview.
4//! Craft has nowhere to put one, so the request is answered by handing the URL
5//! to the system browser and creating nothing — the convention Electron
6//! settled on, and what a person clicking a link expects.
7//!
8//! Which URLs, though, is a security question rather than a routing one. The
9//! page asking is not necessarily trusted: an app that renders a chat
10//! transcript, a feed, or any third-party HTML is asking on behalf of whoever
11//! wrote that content. `NSWorkspace openURL:` is not a browser call — it is
12//! "ask LaunchServices to do whatever this URL means", and what it means is
13//! decided by whatever is installed on the machine.
14//!
15//! So this allows exactly `http` and `https` and drops everything else:
16//!
17//! - `file://` would hand LaunchServices a local path, which for a `.app`,
18//! a `.command`, or anything with a registered handler is a launch. The
19//! `shell.openExternal` bridge does allow `file://`, and should — the app
20//! itself is calling it, on a path it chose. This is a page calling it, on
21//! a URL nobody vetted, and the two do not deserve the same list.
22//! - Custom schemes reach any registered app: `x-apple.systempreferences:`
23//! opens System Settings panes, and every installed app that claims a
24//! scheme is one `window.open` away.
25//! - `javascript:` and `data:` are not the browser's to open at all.
26//!
27//! An app that genuinely wants a wider policy has `shell.openExternal`, where
28//! its own code decides. That is the difference this file is drawing.
29
30const std = ;
31
32pub const Decision = enum {
33 /// Hand it to the system browser and create no webview.
34 open_externally,
35 /// Create nothing and go nowhere. Logged, never silent.
36 drop,
37};
38
39/// The schemes a page may send to the system browser, lower-case and with the
40/// colon, so a prefix match cannot accept `https-evil:`.
41const allowed = [_][]const u8{ "http:", "https:" };
42
43/// What to do with a URL a page asked to open in a new window.
44///
45/// Nothing here parses the URL beyond its scheme: the scheme is the whole
46/// decision, and a parser that tried to do more would be a second place for
47/// the two sides to disagree about what a URL is.
48pub fn decide(url: []const u8) Decision {
49 const scheme_end = std.mem.indexOfScalar(u8, url, ':') orelse return .drop;
50 const scheme = url[0 .. scheme_end + 1];
51
52 // A URL is not required to be short, but a scheme is: RFC 3986 gives no
53 // limit, yet nothing legitimate is close, and this bounds the buffer.
54 if (scheme.len > 16) return .drop;
55
56 var lowered: [16]u8 = undefined;
57 for (scheme, 0..) |c, i| lowered[i] = std.ascii.toLower(c);
58
59 for (allowed) |ok| {
60 if (std.mem.eql(u8, lowered[0..scheme.len], ok)) return .open_externally;
61 }
62 return .drop;
63}
64
65const testing = std.testing;
66
67test "ordinary web links go to the browser" {
68 try testing.expectEqual(Decision.open_externally, decide("https://example.com/a"));
69 try testing.expectEqual(Decision.open_externally, decide("http://example.com"));
70 try testing.expectEqual(Decision.open_externally, decide("https://example.com:8443/a?b=c#d"));
71}
72
73test "the scheme is matched without regard to case" {
74 // `Https://` is a perfectly ordinary link, and a case-sensitive check is
75 // also a bypass: the drop list would be trivially escaped by shifting one
76 // letter.
77 try testing.expectEqual(Decision.open_externally, decide("HTTPS://example.com"));
78 try testing.expectEqual(Decision.open_externally, decide("HtTp://example.com"));
79}
80
81test "a file URL from a page is not opened" {
82 // `shell.openExternal` allows these because the app is calling it about a
83 // path it chose. A page is not the app.
84 try testing.expectEqual(Decision.drop, decide("file:///Applications/Calculator.app"));
85 try testing.expectEqual(Decision.drop, decide("FILE:///etc/passwd"));
86}
87
88test "script and inline-data URLs are not the browser's to open" {
89 try testing.expectEqual(Decision.drop, decide("javascript:alert(1)"));
90 try testing.expectEqual(Decision.drop, decide("JavaScript:alert(1)"));
91 try testing.expectEqual(Decision.drop, decide("data:text/html,<script>alert(1)</script>"));
92}
93
94test "custom schemes cannot reach installed apps" {
95 // Each of these is a real handler on a stock or common macOS install.
96 for ([_][]const u8{
97 "x-apple.systempreferences:com.apple.preference.security",
98 "ftp://example.com",
99 "smb://server/share",
100 "vnc://host",
101 "ssh://host",
102 "itms-apps://apps.apple.com/app/id1",
103 "zoommtg://zoom.us/join?confno=1",
104 "slack://open",
105 "craft://whatever",
106 "mailto:someone@example.com",
107 "tel:+15551234",
108 }) |url| {
109 try testing.expectEqual(Decision.drop, decide(url));
110 }
111}
112
113test "a scheme that merely starts with an allowed one is not allowed" {
114 // The colon is part of the match for this reason.
115 try testing.expectEqual(Decision.drop, decide("https-evil://example.com"));
116 try testing.expectEqual(Decision.drop, decide("httpx://example.com"));
117 try testing.expectEqual(Decision.drop, decide("httpsx:"));
118}
119
120test "something with no scheme at all goes nowhere" {
121 try testing.expectEqual(Decision.drop, decide(""));
122 try testing.expectEqual(Decision.drop, decide("example.com"));
123 try testing.expectEqual(Decision.drop, decide("//example.com"));
124 try testing.expectEqual(Decision.drop, decide("/relative/path"));
125}
126
127test "a leading space does not smuggle a scheme past the check" {
128 // WKWebView hands over an NSURL that has already been parsed, so this
129 // should not arise — but the check must not be the reason it is safe.
130 try testing.expectEqual(Decision.drop, decide(" https://example.com"));
131 try testing.expectEqual(Decision.drop, decide("\thttps://example.com"));
132 try testing.expectEqual(Decision.drop, decide("\nhttps://example.com"));
133}
134
135test "an absurdly long scheme is refused rather than truncated" {
136 var buf: [64]u8 = undefined;
137 @memset(&buf, 'a');
138 buf[63] = ':';
139 try testing.expectEqual(Decision.drop, decide(&buf));
140}
141
142test "a bare scheme with nothing after it is still a web link" {
143 // Degenerate, and NSWorkspace will do nothing useful with it, but it is
144 // not this file's job to invent a second opinion about URL syntax.
145 try testing.expectEqual(Decision.open_externally, decide("https:"));
146}