diff --git a/docs/src/content/docs/rules.md b/docs/src/content/docs/rules.md index f2a6ef1..42fabb3 100644 --- a/docs/src/content/docs/rules.md +++ b/docs/src/content/docs/rules.md @@ -685,6 +685,11 @@ The agent is then expected to re-check anything it actually wants to opt into, including required agreements. `role="checkbox"` widgets and radio groups are out of scope. +The cleared state is held against framework re-renders that would otherwise +silently restore pre-selected values from component state. A genuine user (or +WebDriver-driven) click on the box releases that lock and the toggle sticks +normally; only programmatic re-checks issued by the page itself are reverted. + Pre-checked opt-ins are *Preselection* in Mathur et al. [[9]](#ref-mathur-dark-patterns) and Brignull's deceptive.design catalog [[10]](#ref-brignull). diff --git a/extension/build.ts b/extension/build.ts index b80358d..7f31b2a 100644 --- a/extension/build.ts +++ b/extension/build.ts @@ -131,6 +131,15 @@ async function build(): Promise { // registration call. See `lib/webdriver-probe-source.ts` and // `lib/webdriver-probe-registration.ts`. join(SRC, "webdriver-probe.ts"), + // Standalone main-world bundle registered by the background worker + // whenever `checkout-checkbox-sanitize` is enabled. The patched + // `HTMLInputElement.prototype.checked` setter MUST live in the page + // world — page scripts (React/Vue reconciles) hit the page's own + // copy of the prototype, which is distinct from the one the + // isolated-world content script sees. See + // `lib/checkout-checkbox-defense-source.ts` and + // `lib/checkout-checkbox-defense-registration.ts`. + join(SRC, "checkout-checkbox-defense.ts"), ], outdir: DIST, target: "browser", diff --git a/extension/knip.json b/extension/knip.json index d6cc623..01b3103 100644 --- a/extension/knip.json +++ b/extension/knip.json @@ -7,6 +7,7 @@ "src/popup.tsx", "src/options.tsx", "src/webdriver-probe.ts", + "src/checkout-checkbox-defense.ts", "eslint-rules/index.js" ], "project": ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "eslint-rules/**/*.js"], diff --git a/extension/src/background.ts b/extension/src/background.ts index 3958af8..7369874 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -1,6 +1,8 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. +import { startCheckoutCheckboxDefenseRegistration } from "./lib/checkout-checkbox-defense-registration"; +import { installCheckoutCheckboxDefense } from "./lib/checkout-checkbox-defense-source"; import type { DetectionKind, DetectionPayload, @@ -281,6 +283,35 @@ chrome.runtime.onMessage.addListener( return undefined; } + if (message.type === "inject-checkout-checkbox-defense") { + const tabId = sender.tab?.id; + if (typeof tabId !== "number") { + return undefined; + } + const frameId = sender.frameId; + // Same shape as inject-webdriver-probe: the registered content + // script covers future navigations; this fallback runs the defense + // on the tab the user was already viewing when they toggled the + // rule on. installCheckoutCheckboxDefense's + // `__abs_checkout_checkbox_defense_installed` guard makes a + // redundant call a no-op in the page world. + chrome.scripting + .executeScript({ + target: { + tabId, + frameIds: typeof frameId === "number" ? [frameId] : undefined, + }, + world: "MAIN", + func: installCheckoutCheckboxDefense, + }) + .catch((error: unknown) => { + log("inject-checkout-checkbox-defense executeScript failed", { + error, + }); + }); + return undefined; + } + if (message.type === "rule-count") { const tabId = sender.tab?.id; const frameId = sender.frameId; @@ -384,3 +415,10 @@ startClassifyPortListener(); // content-script-side inline fallback can't reach. See // `lib/webdriver-probe-registration.ts`. startWebdriverProbeRegistration(); + +// Same lifecycle for `checkout-checkbox-sanitize`'s page-world +// `HTMLInputElement.prototype.checked` defense. The patch must live in +// the page world to intercept React/Vue reconciles that drive +// `node.checked = true` through the page's own prototype copy. See +// `lib/checkout-checkbox-defense-registration.ts`. +startCheckoutCheckboxDefenseRegistration(); diff --git a/extension/src/checkout-checkbox-defense.ts b/extension/src/checkout-checkbox-defense.ts new file mode 100644 index 0000000..fb77a1a --- /dev/null +++ b/extension/src/checkout-checkbox-defense.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Build entrypoint for the page-world (main-world) checkout-checkbox +// defense. Registered dynamically by the background worker via +// `chrome.scripting.registerContentScripts` with `world: "MAIN"` and +// `runAt: "document_start"` whenever `checkout-checkbox-sanitize` is +// enabled. Runs before the page's first script so the patched +// `HTMLInputElement.prototype.checked` setter is in place before any +// React/Vue bundle caches the descriptor. +// +// Kept tiny on purpose — the bundled output ships into every page-world +// at document_start, so anything imported here lands in the page's JS +// heap. Pull only from `lib/checkout-checkbox-defense-source.ts`, which +// is dependency-free for the same reason. + +import { installCheckoutCheckboxDefense } from "./lib/checkout-checkbox-defense-source"; + +installCheckoutCheckboxDefense.call(globalThis as unknown as Window); diff --git a/extension/src/lib/__tests__/checkout-checkbox-defense-registration.test.ts b/extension/src/lib/__tests__/checkout-checkbox-defense-registration.test.ts new file mode 100644 index 0000000..c61b11c --- /dev/null +++ b/extension/src/lib/__tests__/checkout-checkbox-defense-registration.test.ts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Exercises the dynamic main-world content-script registration that +// `lib/checkout-checkbox-defense-registration.ts` wires up. Mirrors +// `webdriver-probe-registration.test.ts` — same shape, different script +// id and filename. The module syncs chrome.scripting state against +// (rule-enabled AND enforcement-enabled). + +jest.mock("nanoid", () => ({ nanoid: () => "test-ref" })); +jest.mock("abort-utils", () => ({ + ReusableAbortController: class { + abort(): void { + // noop + } + get signal(): AbortSignal { + return new AbortController().signal; + } + }, + onAbort: (): (() => void) => () => { + // noop + }, +})); + +const defenseRegister = chrome.scripting + .registerContentScripts as unknown as jest.Mock; +const defenseUnregister = chrome.scripting + .unregisterContentScripts as unknown as jest.Mock; +const defenseGetRegistered = chrome.scripting + .getRegisteredContentScripts as unknown as jest.Mock; + +interface DefenseModule { + startCheckoutCheckboxDefenseRegistration: () => void; +} + +interface DefenseScript { + id: string; + matches: string[]; + js: string[]; + runAt: string; + world: string; + allFrames: boolean; +} + +function flushDefense(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }); +} + +async function loadDefenseModule( + overrides: Record, + enforcementEnabled = true, + initiallyRegistered = false, +): Promise<{ module: DefenseModule }> { + let module!: DefenseModule; + + await jest.isolateModulesAsync(async () => { + process.env.EXTENSION_DEFAULT_OVERRIDES = JSON.stringify(overrides); + + const registered: DefenseScript[] = initiallyRegistered + ? [ + { + id: "checkout-checkbox-sanitize-main-world", + matches: [""], + js: ["checkout-checkbox-defense.js"], + runAt: "document_start", + world: "MAIN", + allFrames: true, + }, + ] + : []; + + defenseRegister.mockImplementation( + (scripts: DefenseScript[]): Promise => { + registered.push(...scripts); + return Promise.resolve(); + }, + ); + defenseUnregister.mockImplementation( + (filter: { ids: string[] }): Promise => { + for (const id of filter.ids) { + const index = registered.findIndex((script) => script.id === id); + if (index !== -1) { + registered.splice(index, 1); + } + } + return Promise.resolve(); + }, + ); + defenseGetRegistered.mockImplementation( + (filter?: { ids?: string[] }): Promise => { + if (!filter?.ids) { + return Promise.resolve([...registered]); + } + return Promise.resolve( + registered.filter((script) => filter.ids?.includes(script.id)), + ); + }, + ); + + const enforcement = await import("../enforcement"); + await enforcement.enforcementStorage.set(enforcementEnabled); + + module = await import("../checkout-checkbox-defense-registration"); + }); + + return { module }; +} + +beforeEach(() => { + defenseRegister.mockReset(); + defenseUnregister.mockReset(); + defenseGetRegistered.mockReset(); +}); + +afterEach(() => { + delete process.env.EXTENSION_DEFAULT_OVERRIDES; +}); + +describe("startCheckoutCheckboxDefenseRegistration", () => { + it("registers the main-world script on startup when the rule is enabled", async () => { + const { module } = await loadDefenseModule({ + "checkout-checkbox-sanitize": true, + }); + + module.startCheckoutCheckboxDefenseRegistration(); + await flushDefense(); + + expect(defenseRegister).toHaveBeenCalledTimes(1); + const [scripts] = defenseRegister.mock.calls[0] as [DefenseScript[]]; + expect(scripts[0]).toMatchObject({ + id: "checkout-checkbox-sanitize-main-world", + matches: [""], + js: ["checkout-checkbox-defense.js"], + runAt: "document_start", + world: "MAIN", + // Cart/checkout flows often render payment widgets in same-origin + // iframes; the wrap has to run per-frame because each frame has its + // own HTMLInputElement.prototype. + allFrames: true, + }); + }); + + it("does not register when the rule is disabled", async () => { + const { module } = await loadDefenseModule({ + "checkout-checkbox-sanitize": false, + }); + + module.startCheckoutCheckboxDefenseRegistration(); + await flushDefense(); + + expect(defenseRegister).not.toHaveBeenCalled(); + expect(defenseUnregister).not.toHaveBeenCalled(); + }); + + it("unregisters when an already-registered script becomes ineligible", async () => { + const { module } = await loadDefenseModule( + { "checkout-checkbox-sanitize": false }, + true, + /* initiallyRegistered */ true, + ); + + module.startCheckoutCheckboxDefenseRegistration(); + await flushDefense(); + + expect(defenseUnregister).toHaveBeenCalledWith({ + ids: ["checkout-checkbox-sanitize-main-world"], + }); + }); + + it("does not re-register if the desired state already matches", async () => { + const { module } = await loadDefenseModule( + { "checkout-checkbox-sanitize": true }, + true, + /* initiallyRegistered */ true, + ); + + module.startCheckoutCheckboxDefenseRegistration(); + await flushDefense(); + + expect(defenseRegister).not.toHaveBeenCalled(); + expect(defenseUnregister).not.toHaveBeenCalled(); + }); + + it("treats enforcement-off as if the rule were disabled", async () => { + const { module } = await loadDefenseModule( + { "checkout-checkbox-sanitize": true }, + /* enforcementEnabled */ false, + ); + + module.startCheckoutCheckboxDefenseRegistration(); + await flushDefense(); + + expect(defenseRegister).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/lib/__tests__/checkout-checkbox-defense-source.test.ts b/extension/src/lib/__tests__/checkout-checkbox-defense-source.test.ts new file mode 100644 index 0000000..05e503e --- /dev/null +++ b/extension/src/lib/__tests__/checkout-checkbox-defense-source.test.ts @@ -0,0 +1,215 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://shop.example.com/checkout"} + */ +// Tests for the page-world checkout-checkbox defense — the prototype +// wrap on HTMLInputElement.prototype.checked plus the capture-phase +// `change` listener that releases the lock on trusted user gestures. +// Mirrors how webdriver-probe-source is shipped: the function runs +// inside the page world in production; jsdom's single-world model means +// installing it in the test world exercises the same code path. + +import { installCheckoutCheckboxDefense } from "../checkout-checkbox-defense-source"; +import { isCheckoutUrl } from "../checkout-url"; +import { CHECKOUT_CHECKBOX_CLEARED_ATTR as CLEARED_ATTR } from "../dom-markers"; + +beforeAll(() => { + installCheckoutCheckboxDefense.call(globalThis as unknown as Window); +}); + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("installCheckoutCheckboxDefense — prototype wrap", () => { + it("reverts a programmatic .checked = true on a marked box", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#upsell") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + + // Simulate the page's hydration / controlled-input reconcile loop + // writing the pre-selected state back onto the input. + checkbox.checked = true; + + expect(checkbox.checked).toBe(false); + expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(true); + }); + + it("reverts repeated re-check attempts on a marked box", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#upsell") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + + for (let index = 0; index < 5; index++) { + checkbox.checked = true; + expect(checkbox.checked).toBe(false); + } + }); + + it("allows .click() to legitimately re-check a marked box", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#terms") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + + // .click() routes through the native activation behavior, not the + // patched JS setter. + checkbox.click(); + + expect(checkbox.checked).toBe(true); + }); + + it("allows re-check after the marker is removed", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#terms") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + + checkbox.removeAttribute(CLEARED_ATTR); + checkbox.checked = true; + + expect(checkbox.checked).toBe(true); + }); + + it("does not interfere with unmarked checkboxes", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#fresh") as HTMLInputElement; + + checkbox.checked = true; + + expect(checkbox.checked).toBe(true); + expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(false); + }); + + it("allows .checked = false on a marked box (no-op pass-through)", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#upsell") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + + checkbox.checked = false; + + expect(checkbox.checked).toBe(false); + }); + + it("does not gate writes to .value on a marked non-checkbox input", () => { + // The marker is only ever stamped on checkboxes by the rule, but the + // patch's selectivity must hold even if a hostile page somehow gets + // the marker onto a text input. + document.body.innerHTML = ``; + const input = document.querySelector("#oddball") as HTMLInputElement; + input.setAttribute(CLEARED_ATTR, ""); + + input.value = "hello"; + + expect(input.value).toBe("hello"); + }); +}); + +describe("installCheckoutCheckboxDefense — URL gate", () => { + it("does not defend marked boxes once the SPA route leaves checkout", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#upsell") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + + globalThis.history.replaceState({}, "", "/account"); + try { + checkbox.checked = true; + expect(checkbox.checked).toBe(true); + } finally { + globalThis.history.replaceState({}, "", "/checkout"); + } + }); +}); + +describe("installCheckoutCheckboxDefense — change listener", () => { + // jsdom installs `Event.isTrusted` as a per-instance unforgeable + // property, so a forged-trusted event can't be dispatched through the + // document listener. The negative path (untrusted dispatch keeps the + // marker) is verified here; the positive trusted-gesture path is + // covered in the isolated-world rule's tests by calling the handler + // shape directly. + + it("ignores untrusted change events from page-script dispatch", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#upsell") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + + expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(true); + checkbox.checked = true; + expect(checkbox.checked).toBe(false); + }); +}); + +describe("installCheckoutCheckboxDefense — idempotency", () => { + it("is a no-op when called a second time", () => { + // Already installed in beforeAll. A second invocation must not + // double-wrap (which would capture the prior wrap as "native" and + // lead to layered setter logic). The FLAG on globalThis + // short-circuits the body — verify behavior remains correct. + installCheckoutCheckboxDefense.call(globalThis as unknown as Window); + + document.body.innerHTML = ``; + const checkbox = document.querySelector("#upsell") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + checkbox.checked = true; + expect(checkbox.checked).toBe(false); + }); +}); + +describe("parity with the isolated-world rule", () => { + it("CLEARED_ATTR matches the dom-markers registry constant", () => { + // The page-world source hard-codes the literal because it has no + // module imports at runtime; assert here that the registry constant + // still agrees so a future rename doesn't silently break the + // defense. + // eslint-disable-next-line no-restricted-syntax + expect(CLEARED_ATTR).toBe("data-abs-cleared"); + }); + + it.each([ + "https://shop.example.com/cart", + "https://shop.example.com/cart/", + "https://shop.example.com/checkout", + "https://shop.example.com/checkout/shipping", + "https://shop.example.com/basket", + "https://shop.example.com/bag", + "https://shop.example.com/payment", + "https://shop.example.com/order", + "https://shop.example.com/order/confirmation", + ])("the page-world URL gate accepts the same checkout shapes: %s", (url) => { + expect(isCheckoutUrl(url)).toBe(true); + document.body.innerHTML = ``; + const checkbox = document.querySelector("#t") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + const originalHref = globalThis.location.href; + globalThis.history.replaceState({}, "", new URL(url).pathname); + try { + checkbox.checked = true; + expect(checkbox.checked).toBe(false); + } finally { + globalThis.history.replaceState({}, "", originalHref); + } + }); + + it.each([ + "https://shop.example.com/", + "https://shop.example.com/product/123", + "https://shop.example.com/products/cart-bag", + "https://shop.example.com/orders", + "https://shop.example.com/orders/123", + "https://shop.example.com/account", + ])("the page-world URL gate rejects non-checkout shapes: %s", (url) => { + expect(isCheckoutUrl(url)).toBe(false); + document.body.innerHTML = ``; + const checkbox = document.querySelector("#t") as HTMLInputElement; + checkbox.setAttribute(CLEARED_ATTR, ""); + const originalHref = globalThis.location.href; + globalThis.history.replaceState({}, "", new URL(url).pathname); + try { + checkbox.checked = true; + expect(checkbox.checked).toBe(true); + } finally { + globalThis.history.replaceState({}, "", originalHref); + } + }); +}); diff --git a/extension/src/lib/checkout-checkbox-defense-registration.ts b/extension/src/lib/checkout-checkbox-defense-registration.ts new file mode 100644 index 0000000..6bdda3c --- /dev/null +++ b/extension/src/lib/checkout-checkbox-defense-registration.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Background-side registration for the `checkout-checkbox-sanitize` +// rule's main-world defense. When the rule is enabled, +// `checkout-checkbox-defense.js` is registered via +// `chrome.scripting.registerContentScripts` with `world: "MAIN"` and +// `runAt: "document_start"` so the patched +// `HTMLInputElement.prototype.checked` setter is in place before any +// React/Vue bundle on the page caches the descriptor. +// +// When the rule is disabled (or enforcement is off), the registration is +// removed so future navigations get a clean prototype. Already-loaded +// tabs retain whatever wrap they had until the user reloads — same +// constraint as static content_scripts and as webdriver-probe. +// +// The rule's own `apply` covers the currently-open tab by asking the +// background worker (via an `inject-checkout-checkbox-defense` message) +// to run the defense through `chrome.scripting.executeScript` — dynamic +// registrations only take effect on subsequent navigations, so without +// that round-trip the user would have to reload the active tab. This +// module is purely the registration life-cycle for the standalone +// bundle. + +import { + getEnforcementEnabled, + subscribeEnforcementEnabled, +} from "./enforcement"; +import { log } from "./log"; +import { getRuleStates, subscribe } from "./storage"; + +const SCRIPT_ID = "checkout-checkbox-sanitize-main-world"; +const SCRIPT_FILE = "checkout-checkbox-defense.js"; + +async function shouldBeRegistered(): Promise { + const [states, enforcementEnabled] = await Promise.all([ + getRuleStates(), + getEnforcementEnabled(), + ]); + return enforcementEnabled && Boolean(states["checkout-checkbox-sanitize"]); +} + +async function isRegistered(): Promise { + try { + const registered = await chrome.scripting.getRegisteredContentScripts({ + ids: [SCRIPT_ID], + }); + return registered.length > 0; + } catch (error) { + // getRegisteredContentScripts throws if no script with the id exists + // in some Chrome versions; treat that as "not registered" rather than + // a failure mode that prevents registration. + log("checkout-checkbox-defense registration: getRegistered threw", { + error, + }); + return false; + } +} + +async function register(): Promise { + try { + await chrome.scripting.registerContentScripts([ + { + id: SCRIPT_ID, + matches: [""], + js: [SCRIPT_FILE], + runAt: "document_start", + world: "MAIN", + // allFrames: cart/checkout flows often render the payment widget + // in a same-origin iframe (Stripe Checkout's embedded mode, + // shop-hosted express-pay drawers). Same-origin iframes have + // their own HTMLInputElement.prototype, so the wrap has to run + // per-frame. Cross-origin iframes get the patch too but their + // own document.location gates it via isCheckoutHref. + allFrames: true, + persistAcrossSessions: true, + }, + ]); + log("checkout-checkbox-defense registered at document_start (main world)"); + } catch (error) { + log("checkout-checkbox-defense registration failed", { error }); + } +} + +async function unregister(): Promise { + try { + await chrome.scripting.unregisterContentScripts({ ids: [SCRIPT_ID] }); + log("checkout-checkbox-defense unregistered"); + } catch (error) { + // Unregister fails if the script wasn't registered to begin with; + // that's a benign state, not a problem. + log("checkout-checkbox-defense unregister no-op", { error }); + } +} + +async function sync(): Promise { + const [target, current] = await Promise.all([ + shouldBeRegistered(), + isRegistered(), + ]); + if (target === current) { + return; + } + await (target ? register() : unregister()); +} + +// Wire up the registration life-cycle. Called once from background.ts. +export function startCheckoutCheckboxDefenseRegistration(): void { + // Initial reconciliation when the service worker spins up — covers both + // first install and SW restarts on Chrome's idle timer. + void sync(); + subscribe(() => { + void sync(); + }); + subscribeEnforcementEnabled(() => { + void sync(); + }); +} diff --git a/extension/src/lib/checkout-checkbox-defense-source.ts b/extension/src/lib/checkout-checkbox-defense-source.ts new file mode 100644 index 0000000..09866d5 --- /dev/null +++ b/extension/src/lib/checkout-checkbox-defense-source.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Page-world (main-world) implementation of the checkout-checkbox-sanitize +// defense. The rule itself runs in the isolated world and stamps +// `[data-abs-cleared]` onto every checkbox it unchecks; this source +// holds that state against page-script re-checks by wrapping +// `HTMLInputElement.prototype.checked`. The wrap MUST live in the page +// world — React/Vue reconciles drive `node.checked = true` through the +// page world's own copy of the prototype, which is a distinct object +// from the one a content script sees in its isolated world. Patching +// the isolated-world copy is a no-op for the threat model. +// +// Shared between two delivery paths, mirroring the webdriver-probe +// pattern: +// +// 1. Dynamic content-script registration in the background worker +// (`chrome.scripting.registerContentScripts` with `world: "MAIN"` +// and `runAt: "document_start"`). The primary path for any +// navigation that happens *after* the user enables the rule — +// the patch lands before the page's own scripts cache the +// `.checked` setter. +// +// 2. On-demand `chrome.scripting.executeScript` with `world: "MAIN"`, +// driven by the rule's `apply` sending an +// `inject-checkout-checkbox-defense` message at `document_idle`. +// Covers the tab the user was already viewing when they toggled +// the rule on (dynamic registrations only take effect on subsequent +// navigations). `executeScript` with `world: "MAIN"` is exempt +// from page CSP the same way the registered content script is, so +// strict `script-src` origins still get the patch. +// +// The function must not reference any module-scope identifiers — only +// the function body's source crosses into the page world (either via +// `executeScript({ func })` from the background worker or via the +// bundled `checkout-checkbox-defense.js` entry point). The marker +// string is hard-coded as a literal here and re-declared as +// `CHECKOUT_CHECKBOX_CLEARED_ATTR` in `lib/dom-markers.ts` for the +// isolated-world rule; a test asserts the two agree. + +export function installCheckoutCheckboxDefense(this: Window): void { + const FLAG = "__abs_checkout_checkbox_defense_installed"; + const defenseWindow = this as Window & Record; + if (defenseWindow[FLAG]) { + return; + } + defenseWindow[FLAG] = true; + + // Mirror of CHECKOUT_CHECKBOX_CLEARED_ATTR from lib/dom-markers.ts. + // Hard-coded because this function runs in the page world with no + // module imports; the isolated-world rule and the markers registry + // share the same literal, asserted by a unit test. The lint rule that + // bans inline `data-abs-*` literals exists to keep the registry the + // single source of truth — this is the one principled exception. + // eslint-disable-next-line no-restricted-syntax + const CLEARED_ATTR = "data-abs-cleared"; + + // Mirror of the URLPattern set in lib/checkout-url.ts as a single + // anchored regex. `/cart`, `/cart/`, `/cart/sub` match; `/cartx`, + // `/products/cart-bag`, `/orders` do not. Asserted against the rule's + // `isCheckoutUrl` in a parity test. + const CHECKOUT_PATH_RE = + /^\/(?:cart|checkout|basket|bag|payment|order)(?:\/.*)?$/; + + function isCheckoutHref(href: string): boolean { + try { + return CHECKOUT_PATH_RE.test(new URL(href).pathname); + } catch { + return false; + } + } + + interface CheckedDescriptor { + enumerable?: boolean; + configurable?: boolean; + get?: (this: HTMLInputElement) => boolean; + set?: (this: HTMLInputElement, value: boolean) => void; + } + + const descriptor = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "checked", + ) as CheckedDescriptor | undefined; + if (!descriptor?.configurable || !descriptor.set || !descriptor.get) { + // Non-configurable or partial descriptor — give up silently. A + // future Chrome locking down HTMLInputElement.prototype would + // disable the defense rather than block the page. + return; + } + const nativeSetter = descriptor.set; + const nativeGetter = descriptor.get; + + try { + Object.defineProperty(HTMLInputElement.prototype, "checked", { + configurable: true, + enumerable: descriptor.enumerable ?? true, + get: nativeGetter, + set(this: HTMLInputElement, value: boolean) { + if ( + value && + this.getAttribute(CLEARED_ATTR) !== null && + isCheckoutHref(globalThis.location.href) + ) { + nativeSetter.call(this, false); + return; + } + nativeSetter.call(this, value); + }, + }); + } catch { + // defineProperty can throw if another script has already locked + // the descriptor non-configurable since we read it; treat as the + // same opt-out path as the descriptor guard above. + return; + } + + // Release the defense on genuine user interaction. Without this, a + // controlled React/Vue checkbox would visually flicker on a real-user + // click — native activation toggles `.checked` to true (bypassing our + // setter), the framework's bubble-phase onChange schedules + // `setState(true)` → reconcile → `node.checked = true`, and the patch + // would revert that reconcile because the marker is still present. + // Capture-phase placement guarantees the marker is gone before any + // framework handler runs. Gated on `isTrusted` so page-script + // dispatches (including the rule's own isolated-world `change` event) + // do not release the lock. + document.addEventListener( + "change", + (event) => { + if (!event.isTrusted) { + return; + } + const target = event.target; + if (!(target instanceof HTMLInputElement) || target.type !== "checkbox") { + return; + } + if (target.getAttribute(CLEARED_ATTR) !== null) { + target.removeAttribute(CLEARED_ATTR); + } + }, + { capture: true }, + ); +} diff --git a/extension/src/rules/__tests__/checkout-checkbox-sanitize.property.test.ts b/extension/src/rules/__tests__/checkout-checkbox-sanitize.property.test.ts new file mode 100644 index 0000000..d14c110 --- /dev/null +++ b/extension/src/rules/__tests__/checkout-checkbox-sanitize.property.test.ts @@ -0,0 +1,115 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://shop.example.com/checkout"} + */ +// Property-based tests for checkout-checkbox-sanitize. The rule + the +// page-world defense together guarantee invariants we want fast-check +// to hammer against arbitrary sequences of programmatic state writes: +// +// 1. While the URL is checkout-shaped and the box wears `CLEARED_ATTR`, +// no sequence of `.checked = boolean` writes can leave the box in +// a checked state. (`.click()` is the escape hatch — it routes +// through the activation behavior, not the patched setter.) +// 2. The defense is selective: a fresh checkbox that the rule never +// touched accepts arbitrary `.checked` writes verbatim. +// 3. URL gating is symmetric: once the SPA navigates away from a +// checkout URL, a re-check on the (still-marked) box sticks again. + +import fc from "fast-check"; + +import { installCheckoutCheckboxDefense } from "../../lib/checkout-checkbox-defense-source"; +import { CHECKOUT_CHECKBOX_CLEARED_ATTR as CLEARED_ATTR } from "../../lib/dom-markers"; +import { checkoutCheckboxSanitizeRule } from "../checkout-checkbox-sanitize"; + +beforeAll(() => { + installCheckoutCheckboxDefense.call(globalThis as unknown as Window); +}); + +afterEach(() => { + checkoutCheckboxSanitizeRule.teardown(); + document.body.innerHTML = ""; +}); + +describe("cleared checkbox stays cleared under arbitrary .checked writes", () => { + it("any sequence of programmatic writes resolves to unchecked", () => { + fc.assert( + fc.property( + fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }), + (writes) => { + document.body.innerHTML = ``; + const checkbox = document.querySelector( + "#upsell", + ) as HTMLInputElement; + checkoutCheckboxSanitizeRule.apply(document.body); + expect(checkbox.checked).toBe(false); + + for (const value of writes) { + checkbox.checked = value; + // The invariant: at no point may a programmatic write leave + // the box checked. `value === true` is blocked outright; + // `value === false` is a no-op pass-through. + expect(checkbox.checked).toBe(false); + } + checkoutCheckboxSanitizeRule.teardown(); + document.body.innerHTML = ""; + }, + ), + ); + }); +}); + +describe("non-cleared checkbox is unaffected by the prototype patch", () => { + it("accepts arbitrary .checked writes verbatim", () => { + fc.assert( + fc.property( + fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }), + (writes) => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#fresh") as HTMLInputElement; + // The rule runs on an unrelated subtree; the patch is already + // installed via beforeAll. + checkoutCheckboxSanitizeRule.apply(document.body); + + for (const value of writes) { + checkbox.checked = value; + expect(checkbox.checked).toBe(value); + } + // Marker was never stamped on a box that started unchecked. + expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(false); + checkoutCheckboxSanitizeRule.teardown(); + document.body.innerHTML = ""; + }, + ), + ); + }); +}); + +describe("URL gate releases the defense off checkout", () => { + it("re-checks on a marked box stick once the route is non-checkout", () => { + fc.assert( + fc.property( + fc.constantFrom("/account", "/", "/product/123", "/orders/42"), + (nonCheckoutPath) => { + globalThis.history.replaceState({}, "", "/checkout"); + document.body.innerHTML = ``; + const checkbox = document.querySelector( + "#upsell", + ) as HTMLInputElement; + checkoutCheckboxSanitizeRule.apply(document.body); + expect(checkbox.checked).toBe(false); + expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(true); + + globalThis.history.replaceState({}, "", nonCheckoutPath); + try { + checkbox.checked = true; + expect(checkbox.checked).toBe(true); + } finally { + globalThis.history.replaceState({}, "", "/checkout"); + checkoutCheckboxSanitizeRule.teardown(); + document.body.innerHTML = ""; + } + }, + ), + ); + }); +}); diff --git a/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts b/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts index 3c314c8..48c5a6e 100644 --- a/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts +++ b/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts @@ -2,6 +2,7 @@ * @jest-environment jsdom * @jest-environment-options {"url": "https://shop.example.com/checkout"} */ +import { installCheckoutCheckboxDefense } from "../../lib/checkout-checkbox-defense-source"; import { isCheckoutUrl } from "../../lib/checkout-url"; import { CHECKOUT_CHECKBOX_CLEARED_ATTR as CLEARED_ATTR } from "../../lib/dom-markers"; import { checkoutCheckboxSanitizeRule } from "../checkout-checkbox-sanitize"; @@ -12,6 +13,15 @@ async function flushMutations(): Promise { await Promise.resolve(); } +beforeAll(() => { + // In production the defense is shipped into the page world via a + // separate bundle registered by the background worker; jsdom has a + // single world, so installing the source here gives the rule's + // unchecked boxes the same prototype-wrap defense they'd see at + // runtime. Idempotent — safe to call once per file. + installCheckoutCheckboxDefense.call(globalThis as unknown as Window); +}); + beforeEach(() => { document.body.innerHTML = ""; jest.useFakeTimers(); @@ -105,40 +115,49 @@ describe("checkoutCheckboxSanitizeRule.apply", () => { expect(radio.checked).toBe(true); expect(text.value).toBe("hello"); }); -}); -describe("checkoutCheckboxSanitizeRule lazy-loaded sections", () => { - it("unchecks a checkbox injected after apply()", async () => { + it("requests page-world defense injection via chrome.runtime.sendMessage", () => { + document.body.innerHTML = ``; + const sendMessage = chrome.runtime.sendMessage as unknown as jest.Mock; + sendMessage.mockReset(); + sendMessage.mockResolvedValue(undefined); + checkoutCheckboxSanitizeRule.apply(document.body); - const lazy = document.createElement("div"); - lazy.innerHTML = ``; - document.body.append(lazy); + expect(sendMessage).toHaveBeenCalledWith({ + type: "inject-checkout-checkbox-defense", + }); + }); + it("swallows sendMessage rejections so the rule still scans", async () => { + document.body.innerHTML = ``; + const sendMessage = chrome.runtime.sendMessage as unknown as jest.Mock; + sendMessage.mockReset(); + sendMessage.mockRejectedValue(new Error("no receiver")); + + checkoutCheckboxSanitizeRule.apply(document.body); await flushMutations(); - jest.advanceTimersByTime(MUTATION_THROTTLE_MS); - const checkbox = document.querySelector("#late") as HTMLInputElement; + const checkbox = document.querySelector("#x") as HTMLInputElement; expect(checkbox.checked).toBe(false); expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(true); }); +}); - it("does not re-uncheck a cleared box that the agent re-checks", async () => { - document.body.innerHTML = ``; +describe("checkoutCheckboxSanitizeRule lazy-loaded sections", () => { + it("unchecks a checkbox injected after apply()", async () => { checkoutCheckboxSanitizeRule.apply(document.body); - const checkbox = document.querySelector("#terms") as HTMLInputElement; - expect(checkbox.checked).toBe(false); - - // Agent re-checks the box (e.g., after deciding to accept T&C). - checkbox.checked = true; + const lazy = document.createElement("div"); + lazy.innerHTML = ``; + document.body.append(lazy); - // Trigger a scan: append an unrelated element so the mutation observer fires. - document.body.append(document.createElement("span")); await flushMutations(); jest.advanceTimersByTime(MUTATION_THROTTLE_MS); - expect(checkbox.checked).toBe(true); + const checkbox = document.querySelector("#late") as HTMLInputElement; + expect(checkbox.checked).toBe(false); + expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(true); }); it("teardown stops the observer so later additions are ignored", async () => { @@ -157,3 +176,44 @@ describe("checkoutCheckboxSanitizeRule lazy-loaded sections", () => { expect(checkbox.hasAttribute(CLEARED_ATTR)).toBe(false); }); }); + +describe("end-to-end with the page-world defense installed", () => { + // The rule clears the box and stamps the marker; the source-side + // prototype wrap then defends that marker against programmatic + // re-checks. These tests exercise the integration in jsdom's + // single-world environment, which mirrors how the two pieces interact + // at runtime once the defense bundle has been injected. + + it("a page-script .checked = true after sanitize is reverted", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#upsell") as HTMLInputElement; + + checkoutCheckboxSanitizeRule.apply(document.body); + expect(checkbox.checked).toBe(false); + + checkbox.checked = true; + + expect(checkbox.checked).toBe(false); + }); + + it("an agent .click() on a cleared box re-checks (escape hatch)", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#terms") as HTMLInputElement; + + checkoutCheckboxSanitizeRule.apply(document.body); + checkbox.click(); + + expect(checkbox.checked).toBe(true); + }); + + it("removing the marker manually releases the lock", () => { + document.body.innerHTML = ``; + const checkbox = document.querySelector("#terms") as HTMLInputElement; + + checkoutCheckboxSanitizeRule.apply(document.body); + checkbox.removeAttribute(CLEARED_ATTR); + checkbox.checked = true; + + expect(checkbox.checked).toBe(true); + }); +}); diff --git a/extension/src/rules/checkout-checkbox-sanitize.ts b/extension/src/rules/checkout-checkbox-sanitize.ts index ccca1d4..05b9f94 100644 --- a/extension/src/rules/checkout-checkbox-sanitize.ts +++ b/extension/src/rules/checkout-checkbox-sanitize.ts @@ -9,8 +9,20 @@ // // We re-scan added subtrees via a throttled MutationObserver so checkboxes // in lazily-loaded checkout steps are caught. We deliberately do NOT observe -// attribute mutations: once we've cleared a checkbox, re-checks by the -// agent/user must stick, or we'd be in a fight loop. +// attribute mutations on existing checkboxes via the watcher — the +// MutationObserver path would burn cycles on every class/style toggle. +// +// Defense against post-sanitize re-checks (the dark-pattern threat model +// the rule was built for) lives in a separate page-world bundle: +// `lib/checkout-checkbox-defense-source.ts`, registered by the background +// worker at `document_start` whenever this rule is enabled. The +// isolated-world prototype that a content script can reach is a distinct +// object from the page world's copy that React/Vue reconciles drive +// `node.checked = true` through, so the wrap MUST live in the page +// world. This rule's `apply` sends an `inject-checkout-checkbox-defense` +// message at `document_idle` so the tab the user was already viewing +// when they toggled the rule on also gets the patch — dynamic +// registrations only apply to future navigations. import { isCheckoutUrl } from "../lib/checkout-url"; import { CHECKOUT_CHECKBOX_CLEARED_ATTR as CLEARED_ATTR } from "../lib/dom-markers"; @@ -20,6 +32,10 @@ import type { Rule } from "./types"; const RULE_ID = "checkout-checkbox-sanitize" as const; +const INJECT_DEFENSE_MESSAGE = { + type: "inject-checkout-checkbox-defense", +} as const; + // React/Vue track checked state internally; setting `.checked` directly skips // their value-tracker, so onChange handlers never fire and totals don't // recompute. Going through the prototype's native setter lets the framework @@ -96,7 +112,18 @@ const watcher = createSubtreeWatcher({ }, }); +function requestDefenseInjection(): void { + // Service worker may be asleep / receiver not yet ready; swallow rejection + // so unhandled-promise warnings don't surface on every page load. The + // defense itself short-circuits on `__abs_checkout_checkbox_defense_installed`, + // so re-requests on the same document are no-ops in the page world. + chrome.runtime.sendMessage(INJECT_DEFENSE_MESSAGE).catch(() => { + // noop + }); +} + function apply(root: ParentNode): void { + requestDefenseInjection(); scanAndClear(root); watcher.start(root); } @@ -111,3 +138,5 @@ export const checkoutCheckboxSanitizeRule = { watcher.stop(); }, } satisfies Rule; + +export { INJECT_DEFENSE_MESSAGE }; diff --git a/skills/agent-browser-shield/SKILL.md b/skills/agent-browser-shield/SKILL.md index 75d0112..d33d65b 100644 --- a/skills/agent-browser-shield/SKILL.md +++ b/skills/agent-browser-shield/SKILL.md @@ -55,7 +55,11 @@ surfaces **before you see the page**. 3. **Re-check required checkboxes on `/cart`, `/checkout`, `/basket`, `/bag`, `/payment`, `/order` (and sub-paths).** Every pre-checked box was cleared. Before submitting, explicitly re-check terms-of-service, ship-to-billing, age - confirmation, and any other genuinely-required agreements. + confirmation, and any other genuinely-required agreements. Drive the toggle + through a click (the standard Playwright / CDP / `element.click()` path) — + the extension actively defends the cleared state against direct + `input.checked = true` writes so framework re-renders don't silently restore + pre-selected add-ons. 4. **Text revealed from `reviews-redact`, `comments-redact`, `prompt-injection-redact`, or `encoded-payload-redact` placeholders is