inappwebview_script_guard 0.1.0
inappwebview_script_guard: ^0.1.0 copied to clipboard
Detect silently-failed JavaScript injection in flutter_inappwebview: canary-verified script loading plus commit-time AST linting of embedded JS.
inappwebview_script_guard #
Detect silently-failed JavaScript injection in
flutter_inappwebview:
canary-verified script loading at runtime, plus commit-time AST linting of
the JavaScript you embed in Dart files.
Scope, honestly: this is a
flutter_inappwebviewpackage, not an "any WebView" abstraction. The verifier callsInAppWebViewController.evaluateJavascriptdirectly. The commit-time tooling (invariants.dart+ the executables + the example hooks) is WebView-agnostic — it lints Dart files — but the runtime canary is coupled to flutter_inappwebview by design.
The problem #
evaluateJavascript can swallow failures. A script with a parse error —
or one that throws mid-execution — can resolve the Dart future normally,
report nothing but Script error. @:0:0 on window.onerror (if
anything), and leave your app believing the script loaded. The classic
trigger is two adjacent Dart string literals ('foo ' 'bar') inside an
embedded script: Dart concatenates them silently, V8 rejects the result,
and every feature depending on that script degrades quietly — for days,
if nothing distinguishes "script ran" from "script never parsed".
Three failure classes, three different corrective actions — and without instrumentation they are indistinguishable:
- Script-side failure — parse error, mid-execution throw, page navigated away. Action: investigate the script.
- Bridge-side failure —
evaluateJavascriptitself threw or hung (navigation mid-eval, page disposal, badContentWorld). Action: investigate/report the bridge anomaly. - Stale script — the WebView is running a cached older version. Action: reload / invalidate cache.
This package makes the three distinguishable.
Quick start (runtime canary) #
import 'package:inappwebview_script_guard/inappwebview_script_guard.dart';
// 1. Wrap your literal JS in a JsScript. Keep the payload a raw
// triple-quoted string — no interpolation, no concatenation.
final extractionScript = JsScript(
name: 'extraction',
source: r'''
JSON.stringify((function () {
// ... your script ...
return {ok: true};
})());
''',
);
// 2. Inject + verify in one call.
final (canary, bridgeRaw) =
await injectAndVerifyCanary(controller, extractionScript);
// 3. Route on the verdict.
switch (canary) {
case LoadedResult():
final payload = jsonDecode(bridgeRaw as String);
case MissingResult():
// script-side failure: parse error / mid-run throw / navigation
case InjectThrewResult(:final exception, :final stackTrace):
// bridge-side failure: report exception + stackTrace
case StaleResult():
// cached older script: reload or invalidate cache
case MalformedResult():
// some other JS clobbered the flag: investigate the writer
}
How the canary works #
JsScript.withCanary wraps your source as:
;(function(){
var __canary_result=(function(){return <SOURCE>
})();
window['__canaryLoaded_<name>']='<sha256-first-8>';
return __canary_result;
})();
Three properties are load-bearing (each is locked by a test, and each was violated at some point by a wrap shape that "looked obviously correct"):
- The script's own return value still reaches the bridge. The inner
IIFE
returns your source's value; the outer IIFE captures it and returns the capture — not the canary assignment. Consumers that decodebridgeRawkeep working. - The canary flag is written after the result assignment, in normal
control flow — never in
finally. If the bridge swallows a mid-script throw (the exact failure this package exists to detect), afinally-based flag would still fire and reportloadedfor a script that died. Flag set ⟹ source completed, regardless of bridge swallowing behavior. - The flag value is a content hash, not
true. A flag holding the SHA-256-first-8 of the source distinguishes "loaded the version I shipped" from "loaded something" — which is what turns cache staleness into a first-class verdict.
Verdicts #
| Verdict | Meaning | Corrective action |
|---|---|---|
loaded |
Flag present, hash matches | none — healthy |
missing |
Flag absent | script-side: investigate the JS |
injectThrew |
evaluateJavascript threw or timed out (exception + stack preserved on the result) |
bridge-side: report / retry |
stale |
Flag is valid 8-hex but differs | reload / invalidate WebView cache |
malformed |
Flag set to a non-hash value | some other JS wrote to the flag — investigate the writer, do NOT reload |
CanaryResult is a sealed hierarchy: canary is InjectThrewResult
narrows statically, so exception / stackTrace are non-nullable at the
read site with no guards.
Configuration #
canaryPrefix(JsScript, default'__canaryLoaded') — thewindowproperty prefix. The full flag is'<prefix>_<name>', exposed asscript.canaryFlagName— the single source of truth both the wrap and the read-back derive from.evalTimeout(verifyCanary/injectAndVerifyCanary, default 10 s) — bounds everyevaluateJavascriptawait. A hung bridge call (mid-navigation stall; iOS's async-only eval) routes toinjectThrewwith aTimeoutExceptioninstead of hanging your capture pipeline. NoteinjectAndVerifyCanaryperforms two awaits (inject + read-back), so its worst case is 2× this value.contentWorld— threads to both the inject and the read-back, so isolated-world injections read the flag from the world it was written in. (A shape that threads it only to inject verifies as spuriouslymissing.)
Async scripts #
If your source returns a Promise, the canary fires when the synchronous
prologue completes — it does not wait for the Promise to settle.
loaded means "the script started and returned a Promise," not "the
async work succeeded." Check both the verdict and the awaited result.
This is deliberate: awaiting would couple the canary to user-page network
state.
Two layers of defense #
Parse errors are guarded twice — once before they ship, once at runtime:
Commit time (static): parse validation — lives in
package:inappwebview_script_guard/invariants.dart, the bundled
executables, and the example hook scripts.
checkSource/dart run inappwebview_script_guard:check_js_invariants— AST-only invariant checker (sub-second, no Node). Rejects the Dart-level constructs that silently corrupt embedded JS before they ever reach a device.dart run inappwebview_script_guard:extract_js+node --check— extracts every raw triple-quoted literal and parse-checks it with real V8, so anything the AST rules can't prove is caught by an actual JS parser.
Runtime: the canary — catches what static checks structurally can't
see: mid-execution throws, runtime DOM-shape mismatches, stale-cache
shipping. It is also the backstop for parse failures that somehow get
past commit time: an unparseable script never reaches the canary write,
so it reads back as missing.
Design note — why nothing sits between them. An in-between layer is
conceivable: wrap every payload in eval("…") at runtime so parse
errors propagate as catchable exceptions. This package deliberately
doesn't do that — it would add an eval indirection to every script, and
it detects nothing the two layers above don't already cover
(commit-time node --check catches the parse error before it ships;
the runtime canary reports it as missing if it ships anyway).
The four rejected constructs (commit time) #
Keep raw JS payloads in a dedicated scripts directory and point the
checker at it (--root <dir> — nothing about the directory name is
hard-coded). Files there are restricted to literal JS in raw
triple-quoted strings. The AST checker rejects:
| Construct | Example | Why |
|---|---|---|
| Non-raw multi-line | '''alert($x)''' |
$x is Dart interpolation; ships broken JS |
| Interpolation in multi-line | '''$name''' (no r) |
same as above |
| Adjacent literals | 'foo ' 'bar' |
Dart concatenates silently; the WebView sees invalid JS — the classic silent-failure shape |
String + on literals |
'foo' + 'bar' |
hidden composition; belongs in a composers directory |
Composed JS (templating, conditionals) belongs in a separate composers directory outside the checked root, with unit tests on the composed output.
Per-file skip directive #
A file awaiting migration can opt out with a top-of-file directive (first 10 lines):
// CHECK_JS_INVARIANTS_SKIP_FILE: <reason> #<issue>
Both fields are required — a malformed directive fails the check rather
than silently exempting the file, and skipped files are printed loudly so
review sees them. #<issue> refers to your own tracker.
Git-hook wiring (example scripts, not package API) #
The example/hooks/ directory ships three copy-in scripts:
pre-commit— runs the AST invariants on staged scripts-root files (sub-second).check_js.sh— the full sweep: AST invariants +node --check, then writes a proof-of-work JSON keyed to the SHA-256 of the entire scripts root at HEAD (CRLF-normalized, so Windows and Linux hash identically).pre-push— verifies the proof matches the pushed tree's bytes. Honest-system enforcement: it doesn't re-validate, it confirms validation happened against exactly what ships.
Setup:
flutter pub add inappwebview_script_guard # the hooks run the package's
# executables via `dart run`
cp example/hooks/pre-commit example/hooks/pre-push .githooks/
cp example/hooks/check_js.sh tool/
chmod +x .githooks/pre-commit .githooks/pre-push tool/check_js.sh
git config core.hooksPath .githooks
export JS_SCRIPTS_ROOT=lib/webview_scripts # or edit the scripts
echo .js-check-state/ >> .gitignore # proof files are local state
tool/check_js.sh # first run: 0 violations
(chmod +x matters: git silently ignores a non-executable hook — no
error, the hook just never runs.)
These are examples on purpose: hook policy (bypass variables, proof location, which gates chain together) is repo policy, not package API. Edit them freely.
Testing your integration #
The package's own suite pins the wrap format, the verdict routing, the timeout, and the AST rules — you don't need to re-test those. What's worth testing in your repo:
- a parse test per JS-bearing file (extract +
node --checkin CI or hooks, as above); - your routing of each verdict to the right corrective action.
For fakes, implement InAppWebViewController with noSuchMethod
throwing and override only evaluateJavascript — see this package's
test/canary_verifier_test.dart for the pattern.
When something fails #
- Pre-commit fails with "adjacent string literals": the silent-concatenation shape. Restructure, or move the composition to a composers directory.
- Pre-push fails with "proof stale" / "no proof": the scripts root
changed since the last
check_js.shrun (or it never ran). Re-run it. - Canary
missingat runtime: commit-time checks passed but the script still didn't finish. Most likely a runtime throw (checkwindow.onerror) or page navigation during injection. A log line at the top of the script disambiguates "never started" from "started but threw". - Canary
staleat runtime: the WebView is running a cached copy. Force a reload or invalidate the cache. - Canary
malformed: something else wrote to your flag — don't reload; find the writer (or changecanaryPrefix).
License #
MIT.