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

  1. Grep target="_blank" and target='_blank' in templates. Every hit needs rel containing noopener.
  2. Grep window.open(. Third argument must include noopener (and usually noreferrer).
  3. 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.example is a protocol-relative trap).
  4. Adjacent XSS edge: javascript: and data: in href are 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.html target=_blank without rel: dest reports opener-present: yes, origin: http://127.0.0.1:8000
  • origin_fixed.html with rel="noopener noreferrer": opener-present: no
  • window.open without features string: opener present; with "noopener": null
  • Fix: rel on every _blank, noopener in window.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