This is a browsing-context lab, not a phishing kit. Target is two static HTML files on 127.0.0.1:8000: an origin page that opens a link with target="_blank", and a destination page that reads window.opener. Goal: show opener is non-null without rel="noopener", then show it is null with the attribute. The destination only writes opener-present: yes/no into its own DOM. I do not navigate the opener to a fake login page.
1Figure 1. _blank without rel keeps a handle back to the tab the user trusts.
2origin.html target=_blank dest.html opener != null
3origin_fixed rel=noopener noreferrer dest.html opener == null
Lab layout
1labs/tab_lab/
2 origin.html
3 dest.html
4 origin_fixed.html
1<!-- origin.html -->
2<!doctype html>
3<html>
4<body>
5 <p>lab origin</p>
6 <a id="out" href="http://127.0.0.1:8000/dest.html" target="_blank">open dest</a>
7 <pre id="log">waiting</pre>
8</body>
9</html>
1<!-- dest.html — toy; does not touch opener.location -->
2<!doctype html>
3<html>
4<body>
5 <pre id="out"></pre>
6 <script>
7 var has = window.opener != null;
8 document.getElementById("out").textContent =
9 "opener-present: " + (has ? "yes" : "no") + "\n" +
10 "origin: " + (has ? String(window.opener.location.origin) : "-");
11 /* NOT DONE: window.opener.location = 'http://evil/login' */
12 </script>
13</body>
14</html>
python3 -m http.server 8000 --bind 127.0.0.1. Same origin in this lab so opener.location.origin is readable. Cross-origin, location is opaque but assigning opener.location to a new URL is still allowed in the browsers I tested — that is the nab. I still do not assign it.
Artifact: opener present vs null
Click open dest on origin.html. Destination <pre>:
1opener-present: yes
2origin: http://127.0.0.1:8000
DevTools on the destination:
1window.opener === null
2// false
3window.opener.closed
4// false
5window.opener.document.title
6// "" (origin.html has no title)
Fix file:
1<!-- origin_fixed.html -->
2<a href="http://127.0.0.1:8000/dest.html"
3 target="_blank"
4 rel="noopener noreferrer">open dest</a>
Click again:
1opener-present: no
2origin: -
1window.opener === null
2// true
That pair is the ticket. rel="noopener" drops the handle. noreferrer also strips Referer; I set both on external links. Modern Chromium treats target=_blank as implicit noopener on <a>, but window.open(url, "_blank") without noopener in the features string still hands over opener in the builds I have. I do not rely on the implicit behavior in review.
1// still dangerous in SPA code
2window.open("http://127.0.0.1:8000/dest.html", "_blank");
3// dest sees opener-present: yes
4
5window.open("http://127.0.0.1:8000/dest.html", "_blank", "noopener");
6// dest sees opener-present: no
Analysis steps
- Grep
target="_blank"andtarget='_blank'in templates. Every hit needsrelcontainingnoopener. - Grep
window.open(. Third argument must includenoopener(and usuallynoreferrer). - User-generated hrefs: if the URL is not on an allow-list, it is an external browsing context even when it looks like a relative path (
//evil.exampleis a protocol-relative trap). - Adjacent XSS edge:
javascript:anddata:inhrefare not tabnabbing; they are XSS. Same review pass, different file.
1<!-- BAD -->
2<a href="{{ user_url }}" target="_blank">{{ user_url }}</a>
3
4<!-- GOOD -->
5<a href="{{ user_url | url_allowlist }}"
6 target="_blank" rel="noopener noreferrer">{{ user_url }}</a>
I do not paste a javascript: payload. The allow-list is https: plus hosts we own.
Sanitized reproduction
Two clicks, local only:
1# 1. origin.html → dest.html
2opener-present: yes
3
4# 2. origin_fixed.html → dest.html
5opener-present: no
Harmless demo of what nabbing would do, as a comment in dest, not as running code:
1// NOT ENABLED. If it were:
2// window.opener.location = "http://127.0.0.1:8000/phishing.html";
3// the origin tab would navigate. phishing.html is not in this lab.
Crash analog: open 50 tabs in a loop from the origin (I did this once, it is a DoS on my laptop, not an exploit):
1// lab console, then I killed the tab
2for (var i = 0; i < 50; i++) window.open("/dest.html", "_blank", "noopener");
3// Chrome: "Pages unresponsive" [REDACTED]
Failed-auth log from a site that wraps outbound links in a redirector (/out?u=):
12025-09-12T11:22:04+08:00 GET /out?u=http://127.0.0.1:8000/dest.html
2 cookie: session=[REDACTED]
3 result: 401 # redirector requires login
4# good: unauthenticated users do not mint opener relationships either
The redirector must still emit rel=noopener on the landing <a>, and Referrer-Policy: no-referrer on the 302.
Markdown, Hugo, and user-generated HTML
This blog is Hugo + PaperMod. Gold-renderer behavior is not the same as a raw <a> I paste into a post. I check the build output, not the markdown:
1$ hugo --minify
2$ rg -n "target=\"_blank\"" public/posts | rg -v noopener
3# expect empty
A post that contains a raw HTML <a target="_blank" href="https://example.com"> bypasses Gold’s link renderer. That is why the CI grep is on public/.
User-generated HTML in an app (comments, bios, “docs links”) is worse: the href is not example.com, it is whatever the user saved. Allow-list https: and hosts, then force rel="noopener noreferrer" in the template — do not trust the stored HTML to already contain rel.
1<!-- template, Go html/template -->
2<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">
3 {{ .Label }}
4</a>
referrerpolicy on the element covers older browsers that ignore noreferrer in rel. I still set both.
window.open from a React click handler is the SPA version of the same bug. Third argument "noopener,noreferrer" is required. A wrapper that only passes the URL will regress.
Phishing is out of scope for the repro; tab integrity is in scope. If the destination is on our origin, opener can also read the DOM (same-origin). That is XSS-adjacent. External destinations cannot read the DOM but can still assign location. Both need noopener.
Reverse tabnabbing is quiet: the origin tab’s URL bar changes only when the user looks back. I reproduce it only as opener-present: yes/no. A screenshot of a fake login page is not in the ticket. The DevTools window.opener === null boolean is. I re-test after each frontend dependency bump because at least one “helpful” analytics widget reopened window.open without the features string. The widget vendor’s default is _blank only; we wrap the call in our own helper and re-grep public/.
Mitigation
1<a href="https://example.com/" target="_blank" rel="noopener noreferrer">example</a>
1Referrer-Policy: strict-origin-when-cross-origin
2Cross-Origin-Opener-Policy: same-origin
COOP: same-origin is the strong version: it severs opener relationships across origins even when someone forgets rel. I enable it on apps that do not need to window.open a payment iframe on another origin. If they do, same-origin-allow-popups and explicit rel.
window.opener is also reachable from a <form target="_blank"> submit and from <area target="_blank">. Grep those tags too. A PDF or image opened in a new tab is in scope if the URL is user-controlled.
HTML lint in CI:
1$ rg -n 'target="_blank"' --glob '*.html' | rg -v 'noopener'
2# any remaining line is a finding
3$ rg -n 'window\.open\(' --glob '*.{js,ts,tsx,vue}'
Markdown/Hugo: PaperMod and most renderers now add noopener on target=_blank. I still grep the rendered public/ after hugo, not the markdown, because a raw <a> in a post bypasses the renderer.
What I file after this lab
origin.htmltarget=_blankwithoutrel: dest reportsopener-present: yes,origin: http://127.0.0.1:8000origin_fixed.htmlwithrel="noopener noreferrer":opener-present: nowindow.openwithout features string: opener present; with"noopener": null- Fix:
relon every_blank,noopenerinwindow.open, COOP header, CI grep - Out of scope: a destination that rewrites the opener to a phishing clone
Commands appendix
1python3 -m http.server 8000 --bind 127.0.0.1
2rg -n 'target="_blank"|window\.open' --glob '*.{html,js,vue,tsx}'
3# after hugo:
4rg -n 'target="_blank"' public/ | rg -v noopener