ReviewOS

also looking at this

craft-native/craft

fix(android): an apostrophe in a reply hangs the page forever

#156
Merged glennmichael123 wants to merge fix/android-js-escaping into main
4 files +279 -73
packages/zig/test/android_reply_escaping_test.zigadded+148-0
Changes to packages/zig/test/android_reply_escaping_test.zig
@@ -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
39const std = ;
40const 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.
55const 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
66const 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.
73fn firstUnquotedInterpolation(source: []const u8) ?usize {
74 return std.mem.indexOf(u8, source, "'$");
75}
76
77fn 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
84fn lineOf(source: []const u8, offset: usize) usize {
85 return std.mem.count(u8, source[0..offset], "\n") + 1;
86}
87
88test "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
103test "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
133test "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}