inappwebview_script_guard 0.1.1
inappwebview_script_guard: ^0.1.1 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 mid-run. Action: investigate the script — but the
same
missingverdict also arises when a navigation wiped the world after a healthy run, where the action is the opposite (re-inject once); see Themissingverdict. - 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():
// parse error / mid-run throw — or a navigation wiped the world;
// rule out the wipe (re-inject once) before debugging the JS
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 | two very different causes — rule out a navigation wipe (re-inject once) before investigating the JS; see The missing verdict |
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.
The missing verdict: two causes, opposite responses #
missing means the flag never appeared. That has two cause classes, and
the corrective actions point in opposite directions:
- Script-side failure — a parse error mid-execution, or the body
threw before the canary tail ran. Re-injecting reproduces the failure.
Investigate the JS (
window.onerror, a log line at the top of the script to disambiguate "never started" from "started but threw"). - Navigation wipe — the page navigated between the script running
and your read-back: a redirect, or a WebView restoring its
session and committing the real document after an early
onLoadStopalready fired. The script ran and the flag was written (the two are one synchronous eval — "flag set ⟹ source completed" still holds), but into a document that was then replaced, so the read-back queries a fresh JS world that never had it. (A navigation landing mid-run kills the script before the flag write — cause 1's shape — but the corrective action below is the same either way.) This is easy to hit with the standard inject-at-onLoadStoppattern, and on pages where no secondonLoadStopfollows (session restore, some SPAs), a one-shot inject stays dead until something re-injects.
For cause 2, "investigate the JS" is a dead end — the fix is to re-inject once when the page has settled. A bounded recovery shape:
var pageGeneration = 0; // bump on every page load AND on teardown
Future<void> injectWithRecovery(
InAppWebViewController controller, JsScript script,
{ContentWorld? contentWorld}) async {
final gen = ++pageGeneration;
final (verdict, _) = await injectAndVerifyCanary(controller, script,
contentWorld: contentWorld);
if (verdict is! MissingResult) return; // any non-missing verdict: never retry
unawaited(Future<void>.delayed(const Duration(seconds: 5)).then((_) async {
if (pageGeneration != gen) return; // a later load re-injected anyway
if (scriptSignalsObserved) return; // it was alive all along
final (retry, _) = await injectAndVerifyCanary(controller, script,
contentWorld: contentWorld);
if (pageGeneration != gen) return;
debugPrint(retry.isHealthy
? 'script re-inject recovered (canary ok)'
: 'script re-inject still unhealthy: $retry');
// ONE attempt only — a second miss is a genuine script-side
// failure (or a hostile page); log it and stop.
}));
}
The guards matter as much as the retry:
- Generation / session counter — bump it on every navigation and on
teardown.
Future.delayedhas no cancel handle, so without the teardown bump a pending retry fires into a disposed controller. scriptSignalsObserved— whatever your script emits (JS-handler messages, samples). If signals are flowing, the first inject actually survived and themissingverdict was a read-back race — re-evaluating a live script would double its listeners and timers.- Retry
missingonly.stalemeans a script IS running (an older version — re-evaluating doubles listeners; invalidate the cache instead), andinjectThrewis bridge-side (a re-eval doesn't fix the bridge). - Thread your
contentWorldthrough both calls (as above). A retry that omits it runs — and verifies — in the default world, reporting a verdict about a world your isolated-world script never touched. - Bound it to one attempt. Unbounded retries against a page that genuinely blocks your script spin forever.
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 flag never appeared. First rule out a navigation wipe (redirect / session restore between inject and flag write) — re-inject once when the page settles; see Themissingverdict. If it recurs, it's script-side: a runtime throw (checkwindow.onerror); 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.