inappwebview_script_guard 0.1.0 copy "inappwebview_script_guard: ^0.1.0" to clipboard
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_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. Action: investigate the script.
  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():
    // 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 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 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') — 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.

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 script still didn't finish. Most likely a runtime throw (check window.onerror) or page navigation during injection. 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