Telling a Pop-up You Wanted From One You Did Not
No Popunder blocks the tab that opens behind the one you meant to open. Writing the blocker was the easy half. Not blocking the sign-in window was the real work.
A popunder is the tab that opens behind the tab you meant to open, so you find it an hour later under everything else. No Popunder kills them.
Blocking is the easy half. window.open from a page you did not ask anything of is not hard to catch. The hard half is that some pop-ups are the opposite of a popunder, and the site breaks without them. Version 2.5.0 exists because the extension was blocking sign-in, payment, and share windows.
Nobody files a bug for the popunder that never opened. They file a bug when the Google sign-in window never comes back.
The gesture is a proxy, not proof
The first signal is whether the pop-up followed a real click. Two listeners record the last gesture:
window.addEventListener('pointerdown', onGesture, true);
window.addEventListener('click', onGesture, true);
Three details in those two lines. They run at document_start, so they are registered before the page’s own handlers. They use the capture phase, so they see the real target before a page handler can stop propagation and then call window.open. And onGesture drops anything where e.isTrusted is false, so a script cannot synthesize a click and manufacture consent for itself.
The gesture only counts if it landed on something interactive. The first version of that check listed semantic controls: a[href], button, input, select, textarea, summary, label, [role="button"].
That is exactly where it broke. Plenty of real “Pay” and “Share” and “Sign in” buttons are custom <div> and <span> components. The extension was not catching bad sites, it was punishing sites for their component library.
Every widening needs a narrowing
So the selector grew to accept explicit intent markers: [onclick], [tabindex]:not([tabindex="-1"]), and the link, menuitem, tab, and option ARIA roles.
On its own that would hand the hole straight back. A popunder’s click trap is a transparent full-viewport overlay, frequently carrying its own onclick. Under the wider selector, the trap is indistinguishable from a button.
The thing that separates them is size. A real control is small. A trap has to cover everything, because it needs to catch a click anywhere on the page:
return r.width >= vw * 0.9 && r.height >= vh * 0.9;
A matched element that blankets the viewport does not count as intent. That guard is what makes the widening safe, and it went in as part of the same change rather than as a follow-up.
It also fixed something older. A full-page transparent <a href> overlay always passed the original narrow selector, because it genuinely is an anchor. The new check tightened a case the old code had been getting wrong the whole time.
Destination is better evidence than timing
Gesture timing is circumstantial. Where the pop-up is going is evidence.
Some destinations are never ads. Federated sign-in, checkout windows, and share intents are allowed unconditionally, in every mode, even when they arrive outside the gesture window.
The case that proves why this layer has to exist is Firebase. “Sign in with Google” through Firebase does not open accounts.google.com. It opens:
<project>.firebaseapp.com/__/auth/handler
A user trying to fix a broken login by allowlisting accounts.google.com by hand would never get anywhere, because that is not the host the pop-up is on. There is no amount of user patience that solves this. The extension has to know.
So a pop-up is recognized as auth by any of: an identity-provider host, an identity-platform domain suffix, a well-known OAuth or OIDC endpoint path, or the OAuth 2.0 authorization query signature, which is response_type together with client_id or redirect_uri.
This cannot be turned into a bypass, and the reason is economic rather than technical. There is no ad money on the other side of checkout.stripe.com.
Match hosts at a boundary, never as a substring
An allowlist of payment hosts is a spoofing target, and the obvious implementations both fail:
host.includes('paypal.com')acceptspaypal.com.evil.examplehost.endsWith('paypal.com')acceptsevil-paypal.com
The check that holds is exact match or true subdomain:
host === s || host.endsWith('.' + s)
paypal.com.evil.example is neither. evil-paypal.com ends with paypal.com but not with .paypal.com. Both stay blockable. The leading dot is the entire defense.
The test runs the shipped code
test/auth-detection.test.mjs does not reimplement the heuristic. It reads block.js, slices out the region between two marker comments, and evaluates that:
const start = src.indexOf('// >>> trusted-popup');
const end = src.indexOf('// <<< trusted-popup');
Thirty five cases run against the real shipped function, including the host-spoof attempts above. If the heuristic changes, the test tracks it, because there is only one copy.
This matters more for a heuristic than for ordinary code. A heuristic duplicated into a test file is a heuristic that will quietly stop matching the one users are running, and the test will keep passing while it does.
What I took from it
A blocker is judged on its false positives. The blocks that work are invisible. The one wrong block is the whole product, because it is the only one the user can see.
Widen and narrow in the same commit. Loosening a security check and adding its counterweight are one change, not two. Shipped separately, there is a window where the hole is open.
Prefer evidence about the destination over evidence about the timing. Timing tells you what happened near the pop-up. The URL tells you what it is for.
Match hosts at a boundary. Substring checks on hostnames are a spoof waiting to be written down.
Test the code you ship, not a copy of it. A duplicated heuristic drifts, and the test suite goes green the entire time it is drifting.