Skip to content

Fix: defend cleared checkout checkboxes against programmatic re-checks (#203)#214

Merged
twschiller merged 3 commits into
mainfrom
fix/checkout-checkbox-defend-programmatic-recheck
Jun 7, 2026
Merged

Fix: defend cleared checkout checkboxes against programmatic re-checks (#203)#214
twschiller merged 3 commits into
mainfrom
fix/checkout-checkbox-defend-programmatic-recheck

Conversation

@twschiller
Copy link
Copy Markdown
Contributor

@twschiller twschiller commented Jun 7, 2026

Summary

Closes audit item #13 from #203. checkout-checkbox-sanitize previously
cleared pre-checked boxes once at scan time but never defended against
the page re-checking them: a React controlled input re-renders
node.checked = true from component state on every reconcile, and a
single setState({ optIn: true }) after our pass would silently re-check
every pre-selected add-on.

Architecture

The defense is a wrap on HTMLInputElement.prototype.checked plus a
capture-phase change listener that releases the lock on trusted user
gestures. Both pieces MUST live in the page world: page scripts
(React/Vue reconciles) hit the page world's own copy of the prototype,
which is a distinct object from the isolated-world copy a content script
sees. An isolated-world wrap is a no-op for the threat model.

The page-world bundle follows the established webdriver-probe
pattern — dynamic chrome.scripting.registerContentScripts({ world: "MAIN", runAt: "document_start", allFrames: true }) for navigations
after the rule is toggled on, plus a chrome.scripting.executeScript
fallback driven by the rule's apply to cover the tab the user was
already viewing.

Page-world wrap

When value === true is written to a checked setter whose target
bears [data-abs-cleared] while the URL matches the checkout regex,
the wrap forwards false to the native setter instead. The marker is
the source of truth; the rule (in the isolated world) stamps it on
every box it unchecks, the wrap defends it, and the capture-phase
change listener removes it on the first trusted user gesture so a
controlled framework reconcile of .checked = true doesn't visibly
flicker a real-user click off the screen. Synthetic dispatches from
page scripts (isTrusted === false, including the rule's own change
event) do not release the lock.

Escape hatches for legitimate re-checks

  • A real user click, keyboard Space, or <label> click — dispatches
    a trusted change and releases the marker.
  • checkbox.click() from a Playwright / CDP / WebDriver session — same
    trusted-event path.
  • checkbox.removeAttribute("data-abs-cleared") then .checked = true
    as an explicit out-of-band escape.

Files

  • extension/src/lib/checkout-checkbox-defense-source.ts
    dependency-free installCheckoutCheckboxDefense(this: Window).
    Hard-codes the data-abs-cleared literal (the one principled
    exception to the no-restricted-syntax rule, since page-world code
    has no module imports) with a parity test asserting it tracks the
    registry constant. The URL gate is a regex parallel to
    isCheckoutUrl, also parity-tested.
  • extension/src/checkout-checkbox-defense.ts — tiny build entry that
    calls installCheckoutCheckboxDefense.call(globalThis).
  • extension/build.ts — bundles the new entry alongside
    webdriver-probe.ts.
  • extension/src/lib/checkout-checkbox-defense-registration.ts
    registration/unregistration lifecycle, mirroring
    webdriver-probe-registration.ts.
  • extension/src/background.ts — wires the registration into SW
    startup and handles inject-checkout-checkbox-defense for the
    current-tab fallback via executeScript.
  • extension/src/rules/checkout-checkbox-sanitize.tsapply now
    sends the inject message and keeps only the scan/uncheck/marker
    logic; the page-world wrap is no longer inline.
  • extension/knip.json — declares the new build entry.
  • docs/src/content/docs/rules.md — notes that the cleared state is
    held against framework re-renders but yields to real user / WebDriver
    clicks.
  • skills/agent-browser-shield/SKILL.md item ci: package and upload extension zip artifact #3 — tells agents to
    drive the toggle through a click (the standard Playwright / CDP /
    .click() path) rather than input.checked = true.

Test plan

  • bun jest src/rules/__tests__/checkout-checkbox-sanitize src/lib/__tests__/checkout-checkbox-defense — 60 tests pass
    across the four files, covering: scan/uncheck/marker logic, lazy
    subtree insertion, teardown, sendMessage fallback (including
    rejection handling), prototype-wrap revert/escape/marker-removal/
    URL-gate paths, listener untrusted-ignore, idempotency, parity
    assertions (CLEARED_ATTR literal + URL gate), and chrome.scripting
    registration lifecycle.
  • bun jest full suite — 1792 / 1792 pass (no regression in any
    other rule that touches HTMLInputElement).
  • bun run check (Biome + ESLint) clean.
  • bun run typecheck clean.
  • bun run knip clean.
  • bun run build — background-purity guard reports ok (39 canaries, no leaks). The page-world bundle ships as
    checkout-checkbox-defense.js and is invoked only via
    chrome.scripting.executeScript / registerContentScripts.
  • bun run test:coverage — statements 87.5%, branches 79.3%,
    functions 91.3%, lines 87.5% (above thresholds).
  • pre-commit run --files … clean on all changed files.

🤖 Generated with Claude Code

#203)

Patches the `checked` setter on `HTMLInputElement.prototype` so that any
`input.checked = true` write on a previously-sanitized checkbox is
reverted while the URL is checkout-shaped. Closes audit item #13 — a
single `setState({ optIn: true })` after the rule's pass would otherwise
silently re-check every pre-selected add-on on the next React/Vue
re-render, defeating the whole rule.

Patch gates by both `[data-abs-cleared]` presence and `isCheckoutUrl`,
so fresh checkboxes, every non-checkbox input, and post-checkout SPA
routes are untouched. Agents that genuinely want to re-check a cleared
box use `.click()` (routes through the native activation behavior and
bypasses the JS setter) or remove the marker first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agent-browser-shield-demo-site Ready Ready Preview, Comment Jun 7, 2026 8:35pm

Request Review

Adds a capture-phase `change` listener that removes `[data-abs-cleared]`
on trusted (real user / WebDriver / CDP) interactions. Without this, a
controlled React/Vue checkbox would visually flicker on a real click —
native activation toggles the box to true, the framework reconciles
`node.checked = true` from new component state, and the prototype patch
would revert that reconcile because the marker is still present.
Capture-phase placement guarantees the marker is gone before any
framework handler schedules the reconcile.

Synthetic events (`isTrusted === false`, including page-script
`element.click()` and our own `uncheck` dispatch) do not release the
lock — only genuine user gestures do.

Doc updates:
- `docs/src/content/docs/rules.md`: note that the cleared state is held
  against framework re-renders but yields to real user / WebDriver
  clicks.
- `skills/agent-browser-shield/SKILL.md` item #3: tell agents to drive
  the toggle through a click (the standard Playwright/CDP/`.click()`
  path), not `input.checked = true`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prior commits installed the prototype wrap from the isolated-world
content script — which is a no-op for the threat model. Page scripts
(React/Vue reconciles that drive `node.checked = true` after sanitize)
hit the page world's own copy of `HTMLInputElement.prototype`, a
distinct object from the one a content script sees.

This commit follows the existing `webdriver-probe` pattern to land the
wrap in the page world correctly:

- `lib/checkout-checkbox-defense-source.ts` — dependency-free
  `installCheckoutCheckboxDefense(this: Window)` that wraps the
  `.checked` setter and installs the capture-phase `change` listener.
  Hard-codes the `data-abs-cleared` literal (one principled exception
  to the `no-restricted-syntax` rule because page-world code has no
  module imports) with a parity test asserting it tracks the registry
  constant. URL gate is a regex parallel to `isCheckoutUrl`, also
  parity-tested.
- `checkout-checkbox-defense.ts` — tiny build entry that calls
  `installCheckoutCheckboxDefense.call(globalThis)`.
- `build.ts` — bundles the new entry alongside `webdriver-probe.ts`.
- `lib/checkout-checkbox-defense-registration.ts` —
  `chrome.scripting.registerContentScripts({ world: "MAIN", runAt:
  "document_start", allFrames: true })` when the rule is enabled and
  enforcement is on; unregisters otherwise. `allFrames: true` because
  cart/checkout flows often render payment widgets in same-origin
  iframes whose own prototype needs the wrap.
- `background.ts` — wires the registration into SW startup and handles
  `inject-checkout-checkbox-defense` for the current-tab fallback via
  `chrome.scripting.executeScript`. The fallback exists because dynamic
  registrations only apply to subsequent navigations.
- `rules/checkout-checkbox-sanitize.ts` — drops the (wrong-world)
  inline patch; `apply` now sends the inject message and otherwise
  keeps only the scan/uncheck/marker logic.
- `knip.json` — declares the new build entry so the unused-file check
  passes.

Tests:
- `lib/__tests__/checkout-checkbox-defense-source.test.ts` — new file
  exercising the prototype wrap, URL gate, change-listener untrusted
  ignore, idempotency, and the marker/URL parity assertions.
- `lib/__tests__/checkout-checkbox-defense-registration.test.ts` —
  mirror of `webdriver-probe-registration.test.ts` covering the four
  corners of (rule enabled × enforcement enabled) plus
  already-registered no-op.
- `rules/__tests__/checkout-checkbox-sanitize.test.ts` — now installs
  the defense in `beforeAll` to mirror runtime; keeps rule-side tests
  for apply/teardown/scan and adds end-to-end tests for the
  rule+defense integration. Adds coverage for the new sendMessage path.
- `.property.test.ts` — same `beforeAll` install; invariants unchanged.

All 87 suites / 1792 tests pass. Build reports `background.js purity:
ok (39 canaries, no leaks)`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@twschiller twschiller merged commit 213b2f6 into main Jun 7, 2026
7 checks passed
@twschiller twschiller deleted the fix/checkout-checkbox-defend-programmatic-recheck branch June 7, 2026 20:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant