Fix: defend cleared checkout checkboxes against programmatic re-checks (#203)#214
Merged
twschiller merged 3 commits intoJun 7, 2026
Merged
Conversation
#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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes audit item #13 from #203.
checkout-checkbox-sanitizepreviouslycleared pre-checked boxes once at scan time but never defended against
the page re-checking them: a React controlled input re-renders
node.checked = truefrom component state on every reconcile, and asingle
setState({ optIn: true })after our pass would silently re-checkevery pre-selected add-on.
Architecture
The defense is a wrap on
HTMLInputElement.prototype.checkedplus acapture-phase
changelistener that releases the lock on trusted usergestures. 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-probepattern — dynamic
chrome.scripting.registerContentScripts({ world: "MAIN", runAt: "document_start", allFrames: true })for navigationsafter the rule is toggled on, plus a
chrome.scripting.executeScriptfallback driven by the rule's
applyto cover the tab the user wasalready viewing.
Page-world wrap
When
value === trueis written to acheckedsetter whose targetbears
[data-abs-cleared]while the URL matches the checkout regex,the wrap forwards
falseto the native setter instead. The marker isthe source of truth; the rule (in the isolated world) stamps it on
every box it unchecks, the wrap defends it, and the capture-phase
changelistener removes it on the first trusted user gesture so acontrolled framework reconcile of
.checked = truedoesn't visiblyflicker a real-user click off the screen. Synthetic dispatches from
page scripts (
isTrusted === false, including the rule's ownchangeevent) do not release the lock.
Escape hatches for legitimate re-checks
Space, or<label>click — dispatchesa trusted
changeand releases the marker.checkbox.click()from a Playwright / CDP / WebDriver session — sametrusted-event path.
checkbox.removeAttribute("data-abs-cleared")then.checked = trueas 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-clearedliteral (the one principledexception to the
no-restricted-syntaxrule, since page-world codehas 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 thatcalls
installCheckoutCheckboxDefense.call(globalThis).extension/build.ts— bundles the new entry alongsidewebdriver-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 SWstartup and handles
inject-checkout-checkbox-defensefor thecurrent-tab fallback via
executeScript.extension/src/rules/checkout-checkbox-sanitize.ts—applynowsends 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 isheld against framework re-renders but yields to real user / WebDriver
clicks.
skills/agent-browser-shield/SKILL.mditem ci: package and upload extension zip artifact #3 — tells agents todrive the toggle through a click (the standard Playwright / CDP /
.click()path) rather thaninput.checked = true.Test plan
bun jest src/rules/__tests__/checkout-checkbox-sanitize src/lib/__tests__/checkout-checkbox-defense— 60 tests passacross 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 jestfull suite — 1792 / 1792 pass (no regression in anyother rule that touches
HTMLInputElement).bun run check(Biome + ESLint) clean.bun run typecheckclean.bun run knipclean.bun run build— background-purity guard reportsok (39 canaries, no leaks). The page-world bundle ships ascheckout-checkbox-defense.jsand is invoked only viachrome.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