also looking at this
fix(webview): recover from renderer crashes, and stop dropping window.open()
#72
4 files
+729
-23
| @@ -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 | ||
| 30 | const std = ; | |
| 31 | ||
| 32 | pub 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:`. | |
| 41 | const 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. | |
| 48 | pub 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 | ||
| 65 | const testing = std.testing; | |
| 66 | ||
| 67 | test "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 | ||
| 73 | test "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 | ||
| 81 | test "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 | ||
| 88 | test "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 | ||
| 94 | test "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 | ||
| 113 | test "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 | ||
| 120 | test "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 | ||
| 127 | test "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 | ||
| 135 | test "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 | ||
| 142 | test "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 | } | |