Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/content/docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
9 changes: 9 additions & 0 deletions extension/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ async function build(): Promise<void> {
// 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",
Expand Down
1 change: 1 addition & 0 deletions extension/knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
38 changes: 38 additions & 0 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
19 changes: 19 additions & 0 deletions extension/src/checkout-checkbox-defense.ts
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}

async function loadDefenseModule(
overrides: Record<string, boolean>,
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: ["<all_urls>"],
js: ["checkout-checkbox-defense.js"],
runAt: "document_start",
world: "MAIN",
allFrames: true,
},
]
: [];

defenseRegister.mockImplementation(
(scripts: DefenseScript[]): Promise<void> => {
registered.push(...scripts);
return Promise.resolve();
},
);
defenseUnregister.mockImplementation(
(filter: { ids: string[] }): Promise<void> => {
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<DefenseScript[]> => {
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: ["<all_urls>"],
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();
});
});
Loading
Loading