Skip to content

Repository files navigation

Wiretype

CI

An MV3 extension for Chromium browsers that warns you when third-party trackers read what you type into form fields — before you hit Submit.

Install

Not on the Chrome Web Store yet, so it is loaded unpacked — about a minute:

  1. git clone https://github.com/flyhighbarney/wiretype.git (or download a release and unzip it)
  2. Open chrome://extensions — or edge://, brave://, opera://, vivaldi:// — and turn on Developer mode
  3. Load unpacked → select the extension/ folder, the one containing manifest.json
  4. Pin the toolbar icon, so the badge count is visible

INSTALL.md covers every browser, the difference between the development and release builds, why Firefox and Safari cannot work, and how to confirm the install is live — worth reading, because on most pages Wiretype is correctly silent and silence looks identical to a broken install.

Status

Complete and running. Not yet submitted to the Chrome Web Store — see PUBLISHING.md for what is outstanding.

Step Status
1. Scaffold and win the injection race verified — both fixtures PASS via the loaded extension
2. Sensor core, attribution, perf budget verified — <0.1 ms/keystroke, 25 stack captures per 1122 reads
3. Tracker classification, Phase A scan verified — classification and page scan confirmed in Brave
4. Warning UI overlay complete — rings, tooltip, banner, launcher, panel
5. Extension shell and ship prep complete — 157 automated checks, 48.8 KB zip

Automated coverage: 157 checks across smoke-summary.js (verdict logic and forbidden vocabulary), smoke-trackers.js (matching and list hygiene), smoke-realworld.js (the list against production vendor URLs), check-invariant.js (privacy invariant), and build.js (manifest and packaging). Every suite is negative-tested — each check has been confirmed to fail when the thing it guards is broken.

Tamper resistance

A page that replaces the value getter after we install does not just evade us — it makes us report "nothing detected", which reads as safety. That is a worse outcome than not shipping.

Two mechanisms, both triggered on focusin, which is the last moment before typing and rare enough to stay off the keystroke path:

  • verifyHooks() compares each instrumented prototype's current getter against the one we installed and re-wraps if it changed, chaining through whatever the page put there. Recovers from prototype replacement.
  • probe(el) reads a field and checks whether our getter actually ran. This catches what a descriptor comparison cannot: an own value property defined on the element itself, shadowing the prototype. The value is discarded inside the function under the same invariant as report().

When a bypass cannot be repaired it is reported, not hidden. The verdict turns amber, the number becomes !, and the panel says the result cannot be trusted. smoke-summary.js asserts that tampering outranks a clean result and never outranks an observed read — what we already witnessed still happened.

What is still untested

Ranked by what I expect it to turn up. Nothing below is covered by the suites.

  1. Live sites. smoke-realworld.js now pins the list against 55 production script URLs, which closes the false-negative gap for vendors we know about. It cannot tell us about a vendor absent from the list entirely, nor confirm that a real page's pixel reads a real field. Load a page running Meta Pixel or Hotjar and check the vendor is named rather than the site's own bundle.
  2. contenteditable. Not hooked at all. Rich-text editors are a real coverage gap, not a bug — worth stating as a known limitation.
  3. Password-manager autofill. Writes values without keystrokes; may trigger reads and could produce either false positives or missed detections.
  4. Screen reader. The aria-live announcements have never been heard.

iframe.html, spa.html and csp.html now exist for the three areas that previously had no coverage, but have not been run yet.

Bugs found by reading the code after the UI was already verified: page chrome rendering per-frame, and an unreachable stale-ring condition (!el && !detail.rect, where rect is always set). Both fixed; neither has a regression test yet.

Documents

INSTALL.md Loading it unpacked in every browser, and confirming it is live.
PUBLISHING.md What must be true before this ships. Read this before submitting.
SUBMISSION.md Every Chrome Web Store dashboard field, with the exact value to paste.
STORE.md Listing copy, single-purpose statement, permission justifications.
PRIVACY.md Privacy policy. Needs hosting at a public URL for the store.
SECURITY.md How to report a vulnerability, and what counts as one here.
CHANGELOG.md Release notes.
LICENSE MIT — a default, not yet a considered choice.

Layout

extension/
  manifest.json        MV3. Two content scripts: MAIN (sensor) + ISOLATED (UI).
  trackers.js          Curated list: 56 vendors, 40 flagged highRisk.
  sensor.js            MAIN world, document_start. Hooks .value getters,
                       attributes reads by stack trace, scans page scripts.
                       Field contents never leave this file.
  content.js           ISOLATED world. Renders warnings. Never sees values.
  background.js        Service worker. Per-tab findings, badge, panel state.
  sidepanel.html/.js   Browser side panel. Same data, same verdict logic.
  summarize.js         The verdict: tone, the one big number, its unit, and the
                       sentence. Single source of truth for what we claim.
                       Requireable in Node, so the copy is unit-tested.
  explanations.js      Plain-English vendor copy, and the graded advice.
  icons.js             Nine inline SVG paths, shared by both panels.
  icons/               Generated by tools/make-icons.js.
fixtures/
  race.html            Sensor installs before the first page script; a
                       cross-origin script reads a plain input.
  react.html           React-controlled input, React bundled FIRST-PARTY.
                       Asserts which origin was blamed, not just that we fired.
  perf.html            30 controlled inputs, 200 synthetic keystrokes.
  phase-a.html         Late-injected tracker, no reads. Page scan only.
  ui.html              Overlay checklist: placement, overlap, edges, a11y.
  tamper.html          Three real bypasses: pristine getter from an iframe,
                       delete-and-redefine, and an own property shadowing the
                       prototype. The sensor must recover or say so.
  iframe.html          Cross-origin iframe with its own form and tracker. Rings
                       must appear in-frame; chrome must not.
  spa.html             pushState routing. Rings must not outlive their fields;
                       findings must survive the route change.
  csp.html             Strict policy, no unsafe-inline. Proves a page cannot
                       opt out of being watched, and that shadow styles survive.
  thirdparty/
    tracker.js         Conversion-pixel behaviour: scrapes on load/click/blur.
    replay.js          Session-replay behaviour: reads on every keystroke.
    inert.js           Present but never reads. Phase A must still warn.
tools/
  make-icons.js        Generates the PNGs from code, no image dependency.
  check-invariant.js   Statically enforces "values never leave sensor.js".
  smoke-summary.js     Unit-tests the verdict logic, including the vocabulary it
                       must never emit: blocked, prevented, protected, safe.
  smoke-trackers.js    Hostname matching and list hygiene. Pins the rule that
                       decides who gets publicly accused.
  smoke-realworld.js   Matches the list against 55 production vendor script
                       URLs. Found 7 false negatives on first run.
  build.js             dist/ + zip. Runs all suites; refuses to ship on failure.

Surfaces

  • Field rings and tooltips — on the fields themselves, while you type.
  • Amber banner — bottom left, when a known vendor is merely loaded.
  • Floating launcher — bottom right, like Grammarly's. Red with a count when a read was observed, amber when vendors are only present. Click to open the in-page panel: per-vendor explanation, which fields it read, and what to do.
  • Badge — the same count on the toolbar icon.
  • Native side panel — the same detail via chrome.sidePanel, for browsers that implement it (Chrome 114+).

The in-page panel is the primary surface, not the native one. Brave and some other Chromium browsers do not expose chrome.sidePanel, so an injected panel is the only version that works everywhere — and it can sit beside the form the warning is actually about. The native panel is kept as a bonus where available.

Both panels render from the same data and share explanations.js.

Reading it at a glance

The design target is that a user understands the situation without reading a sentence. Prose is the fallback, not the interface.

  • One number, large. The verdict block leads with a single figure — how many of your fields were read — in 34px mono. That number and the two words beside it are the whole message.
  • Three colour bands. Red means we watched a read happen. Amber means a known form-reader is loaded but nothing has been read. Blue-grey means nothing was found. The band appears on the verdict, the card borders, the badge, and the launcher, so severity is consistent wherever the eye lands.
  • Category glyphs. An eye watches you type, a target measures you, an envelope harvests contacts. Learned once, then recognised without reading the vendor name.
  • Field pills. Which fields were touched, as mono chips. Scannable as data rather than parsed as a list.
  • Two-word badges. DATA READ or NO READ.
  • Prose collapsed. The explanation and the advice sit behind a native <details>. Three paragraphs per vendor is unscannable, and on a page with four vendors it buries the two facts that matter: who, and which fields.

Colour is never the only signal — every band is paired with a text badge, a glyph, and an aria-label, so the hierarchy survives colour blindness and screen readers. opacity is deliberately not used for de-emphasis: it drags body text under the 4.5:1 contrast floor. The loaded tier recedes via an outlined badge and a dimmer border instead.

Why real sites looked quiet

The first live test found almost nothing, on pages that certainly run trackers. Four causes, all fixed:

  1. new FormData(form) was invisible. It serialises every field inside the browser without ever invoking HTMLInputElement.prototype.value, so the getter hook saw nothing. It is the idiomatic way to read a form in modern code, and submit-time harvesting is far more common in the wild than per-keystroke reads. Now wrapped with a Proxy on the constructor.
  2. Reads of empty fields were discarded. The gate was "is there anything to steal", which threw away the most useful signal available: a script that reads a field while it is empty will read it once it is not. Trackers overwhelmingly read on load and on submit, so casual browsing hits the empty read and never the filled one. Now its own tier — see below.
  3. A site's own CDN subdomain counted as third-party. Attribution compared origins, so static.example.com reading a form on example.com was reported as a third party. On themarkup.org we confidently blamed mrkp-static-production.themarkup.org, which is their own bundle. Now compared by registrable domain.
  4. Seven vendors shipped from hosts not on the list, two of them wrong rather than missing — ct.pinterest.com and mixpanel.com looked like coverage but would never have matched a real page. Found by smoke-realworld.js.

One cause is not a bug and worth being honest about: since the 2022 Leaky Forms research, several session-replay vendors changed their defaults to mask inputs, and Meta's Automatic Advanced Matching is no longer on by default. The behaviour is real and still widespread, but it is less universal than it was, and a tool that only ever says "nothing found" will be uninstalled regardless of whether it is right. Hence tier two.

Three warning tiers

Kept deliberately distinct, because conflating them is how a privacy tool becomes noise:

  • Present (amber, weakest) — the page loads a vendor known to read form fields. Shown before you type. Presence is not proof, so it never claims a read. Verdict: "1 tracker can read".
  • Reads your fields (amber, solid outline) — we watched a specific script read a specific field, and the field was empty at the time. Nothing was taken, but the read is proof it will be. This is the tier that fires on real pages, and the one that delivers the actual promise: a warning about this box, before you type in it. Verdict: "1 tracker reads your fields".
  • Data read (red) — a script read a field that had content in it. Verdict: "3 fields read".

A field can be upgraded from tier two to tier three mid-session — the outline goes from amber to red the moment a read catches content — but never downgraded. What we witnessed happened.

Running the fixtures

Two origins are needed so the "tracker" is genuinely cross-origin. The fixtures use localhost for the third party and 127.0.0.1 for the page — different hostnames, and localhost matches a DEBUG-only entry in the tracker list so vendor naming can be tested without calling a real tracker.

cd "Z:/business solutions/wiretype" && python -m http.server 8010 --bind 127.0.0.1
cd "Z:/business solutions/wiretype/fixtures/thirdparty" && python -m http.server 8011

Load the extension: chrome://extensions → Developer mode → Load unpacked → select extension/. Then open the fixtures under http://127.0.0.1:8010/fixtures/.

Each page prints its own PASS/FAIL. Reload the extension (⟳) after any change to extension/ — Chrome serves a cached copy otherwise, and a stale build silently produces stale results.

perf.html deliberately does not self-load the sensor, so it can measure a true extension-disabled baseline.

Findings that shaped the build

Attribution must scan the whole stack

The first build blamed the nearest non-self stack frame. React's _valueTracker defines a value getter on the element instance, so a tracker's read arrives as [our getter] -> [React's getter] -> [tracker]. Taking the nearest frame credits React — and when React is bundled first-party, as real sites do, the read then looks first-party and is dropped entirely. Silent blindness on React apps, no error, no signal.

Attribution now collects every origin on the stack and prefers a known high-risk tracker, then any known tracker, then the outermost third party. Error.stackTraceLimit is raised to 30 for our capture only and restored immediately; V8's default of 10 can be exhausted by framework internals.

The React fixture asserts which origin was blamed, so this cannot regress into a green PASS again.

Dedupe happens after attribution, so backoff is required

We cannot know the calling origin without a stack trace, so a script reading a field on every keystroke was costing a full capture per read — every one of them a duplicate we then discarded. Each element is now sampled on its first 8 reads, then every 64th. The periodic probe still catches a pixel that starts reading later in the session.

Measured against a session-replay scraper: reads rose by 242, captures by 4.

The frame budget is clock-based, not rAF-based

requestAnimationFrame does not fire in a background tab, which would have left the budget permanently exhausted and the sensor blind there.

Meta Pixel and session replay are different products

A conversion pixel scrapes fields around submit-adjacent events; session-replay scripts read on every keystroke. The fixtures model both (tracker.js and replay.js) because a harness that only models one will pass while the other goes undetected — which happened once already.

Privacy invariant

Field contents never leave the getter in sensor.js. The bridge carries a field id, vendor, and geometry — never a value.

This is enforced, not merely documented:

node tools/check-invariant.js

It allowlists the bridge payload keys, rejects any value reaching a network, storage, logging, or messaging sink, confirms the UI layers never touch .value, and requires the side panel to build DOM without innerHTML (it renders vendor names and field labels that came from page markup).

Comments and string bodies are stripped before scanning, so the checks cannot match their own documentation — nor be argued away by it.

build.js runs it and refuses to package on failure. The checks are themselves negative-tested by mutating a copy of the source via WT_EXT_DIR and confirming each one fails.

What the panel does receive: vendor names, field labels (page-authored text such as "Email address"), and field types. Labels are the site author's markup, never anything the user typed.

Building for the store

node tools/build.js

Flips DEBUG off, verifies the manifest, runs the invariant checks, and writes dist/ plus a versioned zip. It aborts rather than shipping a debug build, which would log every field read to the console of every page.

Listing copy is in STORE.md; the mandatory privacy policy is in PRIVACY.md.

About

Warns you when third-party trackers read what you type into web forms — before you press Submit. Chrome extension, MV3, no network requests.

Topics

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages