also looking at this
fix(android): an apostrophe in a reply hangs the page forever
#156
4 files
+279
-73
| @@ -0,0 +1,148 @@ | ||
| 1 | //! How the answer gets back, and the one character that stops it. | |
| 2 | //! | |
| 3 | //! Android replies to the page by building JavaScript source and handing it to | |
| 4 | //! `WebView.evaluateJavascript`. Fifty-eight of those emissions interpolated a | |
| 5 | //! Kotlin value straight into a single-quoted JavaScript string: | |
| 6 | //! | |
| 7 | //! ```kotlin | |
| 8 | //! webView.evaluateJavascript( | |
| 9 | //! "window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')", null) | |
| 10 | //! ``` | |
| 11 | //! | |
| 12 | //! An apostrophe in the value ends the string early, the script does not parse, | |
| 13 | //! and `evaluateJavascript` runs *nothing*. Not the reject, not the resolve — | |
| 14 | //! and because these promises are hand-built rather than made by | |
| 15 | //! `_createCallback`, there is no timeout behind them. The page waits forever | |
| 16 | //! with no console error, since the failure happens inside `evaluateJavascript` | |
| 17 | //! where nothing is watching. | |
| 18 | //! | |
| 19 | //! It is reachable from the page, because Java puts the offending input in the | |
| 20 | //! exception: `craft.calendar.delete("1'x")` throws `NumberFormatException: For | |
| 21 | //! input string: "1'x"`, and that message is what gets interpolated. | |
| 22 | //! | |
| 23 | //! ## Why a scanner and not a review | |
| 24 | //! | |
| 25 | //! The fix is `JSONObject.quote`, which is mechanical — and that is exactly the | |
| 26 | //! kind of fix that comes undone. The next reply path written by hand looks | |
| 27 | //! like the fifty-eight that were there before it, reads naturally, compiles, | |
| 28 | //! and works until someone types an apostrophe. So the rule is checked rather | |
| 29 | //! than remembered. | |
| 30 | //! | |
| 31 | //! ## The rule | |
| 32 | //! | |
| 33 | //! No Kotlin template may contain the two characters `'$`. That is narrower | |
| 34 | //! than "escape your JavaScript" and wider than the reply paths, and both are | |
| 35 | //! deliberate: it is trivially decidable, it has no false positives in this | |
| 36 | //! tree (every occurrence before this file existed was a JavaScript emission), | |
| 37 | //! and a value quoted with `jsQuote` cannot produce it. | |
| 38 | ||
| 39 | const std = ; | |
| 40 | const testing = std.testing; | |
| 41 | ||
| 42 | /// Every Kotlin template in `packages/android/templates`, embedded rather than | |
| 43 | /// read from disk so the test is hermetic. | |
| 44 | /// | |
| 45 | /// All eight, not just the four that talk to the WebView today — a file that | |
| 46 | /// starts emitting JavaScript should already be covered rather than needing | |
| 47 | /// someone to notice. | |
| 48 | /// | |
| 49 | /// The list is hand-written, and that is a real limit: a ninth template is not | |
| 50 | /// scanned until it is added here. Generating the list from the directory was | |
| 51 | /// tried and removed, because `zig build` caches build.zig's evaluation against | |
| 52 | /// that file's contents — adding a template did not regenerate the list, and the | |
| 53 | /// completeness check passed by reading stale data. A check that cannot fail is | |
| 54 | /// worse than an acknowledged gap. | |
| 55 | const templates = [_]Template{ | |
| 56 | .{ .name = "CraftBridge.kt.template", .source = @embedFile("CraftBridge.kt.template") }, | |
| 57 | .{ .name = "CraftBridgeExtensions.kt.template", .source = @embedFile("CraftBridgeExtensions.kt.template") }, | |
| 58 | .{ .name = "CraftHealthConnect.kt.template", .source = @embedFile("CraftHealthConnect.kt.template") }, | |
| 59 | .{ .name = "CraftHealthConnectStub.kt.template", .source = @embedFile("CraftHealthConnectStub.kt.template") }, | |
| 60 | .{ .name = "CraftNative.kt.template", .source = @embedFile("CraftNative.kt.template") }, | |
| 61 | .{ .name = "CraftWidgetProvider.kt.template", .source = @embedFile("CraftWidgetProvider.kt.template") }, | |
| 62 | .{ .name = "LocationRecordingService.kt.template", .source = @embedFile("LocationRecordingService.kt.template") }, | |
| 63 | .{ .name = "MainActivity.kt.template", .source = @embedFile("MainActivity.kt.template") }, | |
| 64 | }; | |
| 65 | ||
| 66 | const Template = struct { | |
| 67 | name: []const u8, | |
| 68 | source: []const u8, | |
| 69 | }; | |
| 70 | ||
| 71 | /// The offset of the first Kotlin interpolation opening immediately inside a | |
| 72 | /// single-quoted JavaScript string, or null if there is none. | |
| 73 | fn firstUnquotedInterpolation(source: []const u8) ?usize { | |
| 74 | return std.mem.indexOf(u8, source, "'$"); | |
| 75 | } | |
| 76 | ||
| 77 | fn sourceOf(name: []const u8) ?[]const u8 { | |
| 78 | for (templates) |template| { | |
| 79 | if (std.mem.eql(u8, template.name, name)) return template.source; | |
| 80 | } | |
| 81 | return null; | |
| 82 | } | |
| 83 | ||
| 84 | fn lineOf(source: []const u8, offset: usize) usize { | |
| 85 | return std.mem.count(u8, source[0..offset], "\n") + 1; | |
| 86 | } | |
| 87 | ||
| 88 | test "the scanner recognises the shape it exists to catch" { | |
| 89 | // Reading the guard below tells you nothing about whether it looks at | |
| 90 | // anything. Three of this repository's tests have passed for the wrong | |
| 91 | // reason and only deliberate mutation exposed them, so the scanner is | |
| 92 | // handed both answers here before it is trusted with the real files. | |
| 93 | try testing.expect(firstUnquotedInterpolation("f('${e.message}')") != null); | |
| 94 | try testing.expect(firstUnquotedInterpolation("f('$id')") != null); | |
| 95 | try testing.expect(firstUnquotedInterpolation("f({key: '$key'})") != null); | |
| 96 | ||
| 97 | // And the fix must read as clean, or the guard is unsatisfiable. | |
| 98 | try testing.expect(firstUnquotedInterpolation("f(${jsQuote(e.message)})") == null); | |
| 99 | try testing.expect(firstUnquotedInterpolation("f($json)") == null); | |
| 100 | try testing.expect(firstUnquotedInterpolation("window.$callback") == null); | |
| 101 | } | |
| 102 | ||
| 103 | test "no Kotlin template interpolates a value into a single-quoted JavaScript string" { | |
| 104 | for (templates) |template| { | |
| 105 | if (firstUnquotedInterpolation(template.source)) |offset| { | |
| 106 | const line = lineOf(template.source, offset); | |
| 107 | const start = if (std.mem.lastIndexOfScalar(u8, template.source[0..offset], '\n')) |nl| | |
| 108 | nl + 1 | |
| 109 | else | |
| 110 | 0; | |
| 111 | const excerpt = std.mem.trim( | |
| 112 | u8, | |
| 113 | std.mem.sliceTo(template.source[start..], '\n'), | |
| 114 | " \t\r", | |
| 115 | ); | |
| 116 | std.debug.print( | |
| 117 | \\ | |
| 118 | \\{s}:{d} interpolates a value into a single-quoted JavaScript string: | |
| 119 | \\ | |
| 120 | \\ {s} | |
| 121 | \\ | |
| 122 | \\An apostrophe in that value makes the emitted script a syntax error, | |
| 123 | \\`evaluateJavascript` runs nothing, and the page's promise never settles. | |
| 124 | \\Use `jsQuote(value)` — it supplies its own quotes, so the surrounding | |
| 125 | \\'...' comes out. | |
| 126 | \\ | |
| 127 | , .{ template.name, line, excerpt }); | |
| 128 | return error.UnquotedInterpolation; | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | test "the templates that answer the page are actually in the scanned set" { | |
| 134 | // A floor, not a count: `CraftBridge.kt` alone holds 119 of these. If this | |
| 135 | // drops to zero the embed has gone stale — pointing at the wrong file, or | |
| 136 | // at a file that no longer talks to the WebView — and the escaping guard | |
| 137 | // above would pass on bytes nobody replies with. | |
| 138 | var emissions: usize = 0; | |
| 139 | for (templates) |template| { | |
| 140 | emissions += std.mem.count(u8, template.source, "evaluateJavascript("); | |
| 141 | } | |
| 142 | try testing.expect(emissions >= 100); | |
| 143 | ||
| 144 | // And the fix is present rather than the sites merely being deleted. | |
| 145 | const bridge = sourceOf("CraftBridge.kt.template").?; | |
| 146 | try testing.expect(std.mem.indexOf(u8, bridge, "private fun jsQuote(") != null); | |
| 147 | try testing.expect(std.mem.count(u8, bridge, "jsQuote(") >= 50); | |
| 148 | } | |