← Back to Blog

Locking the Buy Button: Building a Checkout Guard in MV3

October 2026~9 min read

The Last Moment a Page Can Still Say No

A cart lock has one job: put time between "I want this" and "the order exists". Placement decides what gets interrupted: add to cart interrupts browsing, the cart page misses "Buy now", and a block after the request leaves is theatre.

The buy button is the last point where purchase intent, a cancelable DOM event and observability coincide. src/guard.js opens with the layers it implements — L1 click, L2 checkout link, L3 form submit, L4 checkout-page takeover — and with the constraint that shapes everything else: "DOM only. Never hooks fetch/XHR, never touches real payment pages."

The constraint is structural. A content script runs in an isolated world: it can read and mutate the DOM, but cannot wrap the page's fetch or XMLHttpRequest; that needs main-world injection, where the page can replace the hook. The guarantee is narrower: no order started from a visible page control completes while the timer is active. Background sync, native apps and scripted form.submit() sit outside it.

document_start, all_frames, and the Cost of Being Early

The content script declaration in manifest.json matters most:

"content_scripts": [{
  "matches": ["<all_urls>"],
  "js": ["src/checkout-rules.js", "src/core.js", "src/message-packs.js", "src/guard.js"],
  "run_at": "document_start",
  "all_frames": true
}]

document_start is about ordering, not speed. A framework that delegates clicks at the page root registers its listener during hydration; a capture-phase listener installed earlier gets the first look. At document_idle, a page listener could call stopImmediatePropagation() first and the guard would never run.

all_frames is about event boundaries. A click inside a frame never reaches the top document, so a top-only guard is blind to framed checkouts. Every frame gets its own host and decision; window.__VKT_CART_LOCK_GUARD__ prevents a second copy.

The cost of <all_urls> is paid on every load, and the guard bails before spending it: missing modules, a second injection or a payment host returns before listeners and storage reads. Being early also means existing before the state does: loadState() sets stateReady asynchronously, and until then clicks pass. Blocking on state would freeze unrelated clicks, so the code takes the miss.

Capture, Not Bubble: Clicks, Submits and the Keys Around Them

All checkout interception is wired in five lines:

window.addEventListener('click', onCheckoutClick, true);
document.addEventListener('click', onCheckoutClick, true);
window.addEventListener('auxclick', onCheckoutClick, true);
document.addEventListener('submit', onSubmit, true);
window.addEventListener('submit', onSubmit, true);

The third argument is the point: capture runs from window to target before any bubble handler on the button, form or app root. auxclick covers middle-click. The window and document registrations are redundant in propagation; e.__vktHandled makes them harmless.

The handler starts from e.composedPath()[0] || e.target because the clicked element may live inside the site's shadow root. It returns if the path contains the overlay; while the overlay is shown, every page click is blocked. On a match, handleCheckout() cancels the event with preventDefault(), stopPropagation() and stopImmediatePropagation() before any state mutation. A canceled event cannot be resumed, which is why confirm synthesizes a click.

The submit path is separate: find the form's submit button and match it; if that fails, ask whether the form looks like checkout — an action URL matching the checkout patterns, or card fields such as input[autocomplete="cc-number"] and a CVC input. Enter in a field can fire submit with no click. Caveat: HTMLFormElement.submit() fires no submit event, so script-driven submission bypasses L3; requestSubmit() is caught.

keydown is not a fourth detection channel: it is installed only while the overlay is visible, to consume Escape and Enter and trap Tab. Enter on a focused checkout button still produces a click.

A Modal in a Page That Wants It Gone

The overlay is one host element appended to document.body, with an open shadow root and a constructed stylesheet:

const hostEl = document.createElement('div');
hostEl.id = 'vkt-cart-lock-overlay';
hostEl.style.cssText = 'all: initial; position: fixed; inset: 0; z-index: 2147483647;';
const root = hostEl.attachShadow({ mode: 'open' });
let sheet = null;
try {
  sheet = new CSSStyleSheet();
  sheet.replaceSync(OVERLAY_CSS);
  root.adoptedStyleSheets = [sheet];
} catch (e) { /* fallback: a style element in the shadow root */ }

all: initial on the host cuts inherited properties, and :host { all: initial; } does the same for the shadow root. Without them, a store's global typography leaks into the countdown. The host's inline positioning beats ordinary page rules; an author !important rule can still override it, so the backdrop is also position: fixed.

Visual isolation is not a security boundary: the root is open, the id guessable, and page script can reach both. Page CSS must not deform the dialog.

The page behind is blurred by walking document.body.children and setting pointer-events: none, filter: blur(6px) and user-select: none. A MutationObserver covers nodes added later, and a Map stores each node's previous inline value and priority for restoreBlur(). The trade: childList is observed, not attributes, so a page inline change made while the modal is open loses to the saved value.

The card carries role="dialog", aria-modal and aria-labelledby; focus moves to the return button 30 ms after it appears, and Tab is trapped. It is not a native <dialog>: no top layer, no inert background, no managed focus restoration.

For a same-origin checkout frame, notifyTop() posts the timer to window.top, which accepts it only when e.origin === location.origin, so the user sees one modal. For a cross-origin frame the check rejects the message: the overlay stays inside that frame and the rest of the page stays interactive. A DOM-only extension cannot paint over it from the parent.

A Deadline in Storage, Not a Ticking Counter

Timers live in chrome.storage.local under vkt_cl_timers, keyed by host or host::itemId. createTimer() in src/core.js sets expiresAt = now + cooldownMinutes * 60000; nothing rewrites it as time passes. The overlay's one-second interval recomputes expiresAt - Date.now() for display only; the badge recomputes on tab activation and the popup on open. A browser restart re-reads a deadline; it does not resume a countdown. chrome.storage.onChanged propagates it to other tabs.

The one-minute vkt_cl_tick alarm is housekeeping, not countdown. runHousekeeping() deletes timers past graceEnd() — expiresAt plus one more cooldown — prunes stats, revalidates the license at most every six hours, and refreshes the badge. ensureAlarm() runs at top level, on install and startup. Alarms survive service worker termination but arrive approximately: right for maintenance, wrong as the clock.

The trade is a wall-clock deadline: an NTP correction or manual clock change moves the remaining time. No monotonic counter survives a restart; no anti-tamper exists. Concurrent writes go through S.withLock() (navigator.locks or a promise chain); when contexts disagree, the later expiresAt wins and the higher blockedCount is preserved — the sync merge's "later expiresAt always wins."

Payment Domains Fail Open; Success Pages Re-Lock

The payment-host check is a hard stop in three places. In guard.js it runs before listeners are attached: if (!HOST || R.isPaymentHost(HOST)) return;. handleStartLock() refuses with bad_host, and isCheckoutPage() returns false for a payment host even when the URL looks like checkout. The list is 21 regular expressions covering Stripe, PayPal, Adyen, Braintree, Square, Klarna, Affirm, Afterpay and others.

Fail-open is operational: a missing host, unparseable URL, failed storage read or unknown state all block nothing; loadState() sets stateReady with defaults in its catch. Interrupting a payment is worse than missing one block.

But a denylist is only as good as its contents: PayU, Mercado Pago, YooKassa and iyzico are absent, as is a payment step on the merchant's own domain. isCheckoutUrl() includes /payment and isCheckoutPage() accepts card fields, so that step is in scope. "Never touches real payment pages" is precise only for the processors in PAYMENT_HOSTS; card values are never read.

Purchases move in the opposite direction. When the URL matches SUCCESS_URL_RE, the guard reports VKT_PURCHASE once per URL. The background requires an existing timer, records the purchase, deletes every host timer, and creates a fresh site-scoped one now:

const fresh = C.createTimer({
  key: host, host: host, itemId: null, settings: state.settings, now: now,
  cart: { total: 0, currency: '', items: 0 }, scope: 'site', source: 'purchase'
});
state.timers[host] = fresh;

Buying does not unlock; it restarts the lock. Duplicate orders and upsells happen after the first payment, not before it. With no prior timer, handlePurchase() returns no_timer and creates nothing.

Detection is URL-based: a single-page checkout that swaps to a success view without changing the URL does not report, and a marketing path like /order-confirmation can report a purchase that never happened, though it needs an active timer. reportPurchaseUrl prevents duplicates while the URL is unchanged.

What Removing the Skip Button Does to the Code

The overlay offers a return button, an optional confirm after expiry, and emotion chips. There is no close icon and no backdrop handler. While it is shown, every click is blocked, Escape and outside-Enter are consumed, and the page behind is non-interactive.

The lifecycle changes. A dismissible modal is closed by the user, so its teardown lives in the close handler; this one is closed by state. closeOverlay() has five routes: a storage change that leaves no applicable timer, a pass or exempt decision in checkPage(), a success URL, the 2.2-second "released" message after expiry, and the return or confirm button.

if (e.key === 'Escape') {
  e.preventDefault();
  e.stopImmediatePropagation();
  return;
}
if (e.key === 'Enter' && !inside) {
  e.preventDefault();
  e.stopImmediatePropagation();
  return;
}

The whitelist follows the same logic. decideCheckout() checks for an active timer before isWhitelisted(), so adding a site to the allowlist never shortens a running lock. The popup says it directly: "An active timer must still finish. Adding a site here never unlocks it." A misclassification has no in-page remedy.

The only voluntary unlock is confirm-after-expiry, gated on Pro, the confirmAfterExpiry setting, remaining <= 0, the grace window and no prior confirmation. It records confirmedAt, closes, and replays node.click() after 120 ms if that node is connected — the only way to resume an intent preventDefault() canceled. The form-only path records no node, so there is no reliable replay.

Closing sets suppressUntil = Date.now() + 2500. Without it, a mutation on the still-open checkout page can reopen the overlay before navigation completes — a debt paid to a takeover layer triggered by DOM changes, not gestures.

A modal that cannot be dismissed is hostile by default; focus management, dialog semantics, a route back and explanatory copy make it bearable, not benign. It is defensible only if matching rarely false-positives — the subject of Selector Drift Across 24 Checkout Flows. The extension is VKT Cart Lock.

Related Articles