inappwebview_script_guard 0.1.1 copy "inappwebview_script_guard: ^0.1.1" to clipboard
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_inappwebview package, not an "any WebView" abstraction. The verifier calls InAppWebViewController.evaluateJavascript directly. 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:

  1. Script-side failure — parse error, mid-execution throw, page navigated away mid-run. Action: investigate the script — but the same missing verdict also arises when a navigation wiped the world after a healthy run, where the action is the opposite (re-inject once); see The missing verdict.
  2. Bridge-side failureevaluateJavascript itself threw or hung (navigation mid-eval, page disposal, bad ContentWorld). Action: investigate/report the bridge anomaly.
  3. 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 decode bridgeRaw keep 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), a finally-based flag would still fire and report loaded for 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') — the window property prefix. The full flag is '<prefix>_<name>', exposed as script.canaryFlagName — the single source of truth both the wrap and the read-back derive from.
  • evalTimeout (verifyCanary / injectAndVerifyCanary, default 10 s) — bounds every evaluateJavascript await. A hung bridge call (mid-navigation stall; iOS's async-only eval) routes to injectThrew with a TimeoutException instead of hanging your capture pipeline. Note injectAndVerifyCanary performs 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 spuriously missing.)

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:

  1. 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").
  2. 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 onLoadStop already 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-onLoadStop pattern, and on pages where no second onLoadStop follows (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.delayed has 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 the missing verdict was a read-back race — re-evaluating a live script would double its listeners and timers.
  • Retry missing only. stale means a script IS running (an older version — re-evaluating doubles listeners; invalidate the cache instead), and injectThrew is bridge-side (a re-eval doesn't fix the bridge).
  • Thread your contentWorld through 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 --check in 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.sh run (or it never ran). Re-run it.
  • Canary missing at 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 The missing verdict. If it recurs, it's script-side: a runtime throw (check window.onerror); a log line at the top of the script disambiguates "never started" from "started but threw".
  • Canary stale at 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 change canaryPrefix).

License #

MIT.

0
likes
150
points
13
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Detect silently-failed JavaScript injection in flutter_inappwebview: canary-verified script loading plus commit-time AST linting of embedded JS.

Repository (GitHub)
View/report issues

Topics

#webview #javascript #injection #observability

License

MIT (license)

Dependencies

analyzer, crypto, flutter, flutter_inappwebview

More

Packages that depend on inappwebview_script_guard