Web Security for Frontend Devs · Part 18 — Reverse Tabnabbing & window.opener
Bonus track: how a link you open can silently navigate your original tab to a phishing page via window.opener — why modern browsers mostly fixed it, where it still bites, and the safe-link defenses. With a live simulator and labs.
Part 18 — Bonus track in the Web Security for Frontend Devs series {Phần 18 — Nhánh bonus trong series Web Security for Frontend Devs}. Previous {Trước}: Part 17 — Supply-Chain Attacks via npm install · Next {Tiếp}: Part 19 — Clipboard & Autofill Attacks.
You add a perfectly ordinary link: <a href="https://partner.com" target="_blank"> {Bạn thêm một link hết sức bình thường}. The user clicks, a new tab opens, they read it — and meanwhile your original tab has silently changed into a pixel-perfect phishing copy of your login page {Người dùng bấm, tab mới mở ra, họ đọc — trong khi đó tab gốc của bạn đã âm thầm biến thành bản sao phishing y hệt trang login của bạn}. They switch back, see your familiar layout, and type their password {Họ quay lại, thấy giao diện quen, và gõ mật khẩu}. That is reverse tabnabbing, and the whole attack rides on one property: window.opener {Đó là reverse tabnabbing, và cả vụ tấn công dựa trên một thuộc tính: window.opener}.
This is a frontend bug every developer ships at least once {Đây là lỗi frontend mà dev nào cũng ship ít nhất một lần}. Browsers fixed most of it by default in 2021 — but there are real holes left, and they are exactly the cases you reach for {Trình duyệt đã vá phần lớn mặc định năm 2021 — nhưng vẫn còn lỗ thật, đúng vào những trường hợp bạn hay dùng}.
The one mental model {Mô hình tư duy cốt lõi}
When a page opens another browsing context (a tab/window), the new page can get a reference back to the page that opened it via window.opener {Khi một trang mở context khác (tab/cửa sổ), trang mới có thể nhận tham chiếu ngược về trang đã mở nó qua window.opener}:
Your tab (opener) New tab (the destination)
opens ───────────────────────────▶ gets window.opener ───┐
◀────────────────────────────────── window.opener.location = "phishing" ┘
The crucial, surprising rule {Quy tắc then chốt, đáng ngạc nhiên}: even when the destination is on a different origin and cannot read your page, it can still navigate it — setting window.opener.location is a write that the same-origin policy does not block {kể cả khi đích ở origin khác và không đọc được trang bạn, nó vẫn điều hướng được trang đó — gán window.opener.location là một thao tác ghi mà same-origin policy không chặn}.
// runs on the attacker's destination page:
if (window.opener) {
window.opener.location = "https://your-site.example.attacker.com/login";
}
No XSS on your site, no stolen cookie — the attacker simply repaints the tab the victim already trusts {Không XSS trên site bạn, không trộm cookie — kẻ tấn công chỉ vẽ lại tab nạn nhân vốn đã tin}.
Why it usually doesn’t fire today {Vì sao nó thường không nổ hôm nay}
In 2021 the HTML spec changed so that target="_blank" on a link implies rel="noopener" {Năm 2021 spec HTML đổi để target="_blank" trên link ngầm định rel="noopener"}. With noopener, the new page’s window.opener is null {Với noopener, window.opener của trang mới là null}. This shipped in Chrome/Edge 88, Firefox 79, and Safari 12.1 {Có từ Chrome/Edge 88, Firefox 79, Safari 12.1}. So a plain anchor in a modern browser is safe by default {Vậy một anchor thường trên trình duyệt hiện đại là an toàn mặc định}.
That is great — and also why this bug is now underestimated. The default only covers one of the ways you open tabs {Mặc định chỉ phủ một trong các cách bạn mở tab}.
Where it still bites {Nơi nó vẫn cắn}
The implicit noopener applies to anchor target="_blank" — not to everything {noopener ngầm áp dụng cho anchor target="_blank" — không phải mọi thứ}:
window.open()does not implynoopener{window.open()không ngầmnoopener}. The returned window keeps a liveopenerunless you pass the feature explicitly:window.open(url, '_blank', 'noopener'){Window trả về vẫn giữopenersống trừ khi bạn truyền feature}.rel="opener"explicitly re-enables the opener on an anchor, overriding the modern default {rel="opener"bật lại opener trên anchor, ghi đè mặc định}. People add it back to “fix” a broken popup flow and silently reopen the hole {Người ta thêm lại để “sửa” popup hỏng và vô tình mở lại lỗ}.- Old browsers / embedded webviews {Trình duyệt cũ / webview nhúng} — some in-app browsers and legacy engines never got the implicit default {vài in-app browser và engine cũ chưa có mặc định ngầm}.
- User-generated content {Nội dung do người dùng tạo} — a Markdown renderer or rich-text field that emits
<a target="_blank">withoutrelis fine on modern browsers, but the moment a template useswindow.openor addsrel="opener", untrusted links become live openers {một renderer Markdown phát<a target="_blank">khôngrelthì ổn trên trình duyệt hiện đại, nhưng khi template dùngwindow.openhay thêmrel="opener", link không tin cậy thành opener sống}.
There is also a quieter leak that survives the default {Còn một rò rỉ âm thầm sống sót mặc định}: unless you add noreferrer, the destination still receives your page’s URL in the Referer header / document.referrer, which can leak tokens-in-URLs or internal paths {trừ khi thêm noreferrer, đích vẫn nhận URL trang bạn trong Referer / document.referrer, có thể lộ token-trong-URL hay path nội bộ}.
Key distinction {Phân biệt then chốt}:
noopenersevers the scripting/navigation link (window.opener = null);noreferreralso drops the Referer (and impliesnoopener) {noopenercắt liên kết script/điều hướng;noreferrercòn bỏ Referer (và ngầmnoopener)}.
Try it — live reverse-tabnabbing simulator {Thử ngay — trình mô phỏng reverse tabnabbing}
Pick how the link opens (<a target="_blank"> vs window.open()), toggle noopener / noreferrer / rel="opener", switch between a modern and a legacy browser, and add a Cross-Origin-Opener-Policy header {Chọn cách mở link, bật/tắt noopener/noreferrer/rel="opener", đổi giữa trình duyệt hiện đại và cũ, và thêm header Cross-Origin-Opener-Policy}. Open the link and the destination reports whether it got a live window.opener — if it did, fire the attack and watch your original tab get swapped for a phishing page {Mở link và đích báo có nhận window.opener sống không — nếu có, bắn tấn công và xem tab gốc bị tráo bằng trang phishing}. Fully simulated; no real windows open {Hoàn toàn mô phỏng; không mở cửa sổ thật}.
Open the full demo {Mở demo đầy đủ}: /tools/reverse-tabnabbing-demo/.
Hands-on labs — reproduce it safely {Lab thực hành — tái hiện an toàn}
Prove the mechanism in a real browser {Chứng minh cơ chế trong trình duyệt thật}. The “phishing” page here just shows a banner — nothing is stolen {Trang “phishing” ở đây chỉ hiện banner — không trộm gì}.
Serve over HTTP so
window.openerbehaves consistently (rawfile://origins differ across browsers) {Phục vụ qua HTTP đểwindow.openernhất quán (originfile://thô khác nhau giữa trình duyệt)}.
Shared setup {Chuẩn bị chung}
mkdir -p /tmp/tabnab && cd /tmp/tabnab
# 3 pages: your page, the attacker destination, the phishing look-alike
cat > attacker.html <<'EOF'
<!doctype html><meta charset="utf-8"><title>partner</title>
<h1>partner.example (attacker-controlled)</h1>
<pre id="out"></pre>
<button id="go">Run reverse tabnabbing</button>
<script>
out.textContent = "window.opener is " + (window.opener ? "LIVE ⚠" : "null ✓")
+ "\ndocument.referrer = " + JSON.stringify(document.referrer);
go.onclick = () => {
if (window.opener) window.opener.location = "phish.html";
else out.textContent += "\n(can't touch the opener — null)";
};
</script>
EOF
cat > phish.html <<'EOF'
<!doctype html><meta charset="utf-8"><title>Sign in</title>
<h1 style="color:#06f">🔒 Session expired — please sign in</h1>
<p>Your ORIGINAL tab was navigated here by the other tab. (Demo only.)</p>
EOF
npx serve -p 5000 . >/dev/null 2>&1 & # or: python3 -m http.server 5000
echo "open http://localhost:5000/your.html"
Lab 1 — window.open() keeps a live opener {Lab 1 — window.open() giữ opener sống}
cat > /tmp/tabnab/your.html <<'EOF'
<!doctype html><meta charset="utf-8"><title>Your site</title>
<h1>your-site.example</h1>
<button onclick="window.open('attacker.html','_blank')">Open (vulnerable)</button>
<button onclick="window.open('attacker.html','_blank','noopener')">Open (noopener ✓)</button>
EOF
Open your.html, click “Open (vulnerable)”, and in the new tab note window.opener is LIVE; press its button and your first tab navigates to the phishing page {Mở your.html, bấm “Open (vulnerable)”, tab mới báo window.opener is LIVE; bấm nút của nó và tab đầu chuyển sang trang phishing}. Now try “Open (noopener ✓)” — window.opener is null, the attack button does nothing {Giờ thử “noopener” — null, nút tấn công vô hại}.
Proved {Đã chứng minh}: window.open() does not get the modern default; you must pass 'noopener' {window.open() không có mặc định hiện đại; phải truyền 'noopener'}.
Lab 2 — the anchor default, and rel="opener" reopening it {Lab 2 — mặc định anchor, và rel="opener" mở lại}
cat > /tmp/tabnab/your.html <<'EOF'
<!doctype html><meta charset="utf-8"><title>Your site</title>
<h1>your-site.example</h1>
<a href="attacker.html" target="_blank">Plain target=_blank (safe on modern)</a><br>
<a href="attacker.html" target="_blank" rel="opener">With rel="opener" (re-enabled ⚠)</a>
EOF
The plain link reports window.opener is null on any modern browser {Link thường báo null trên trình duyệt hiện đại}. The rel="opener" link reports LIVE — you opted back into the danger {Link rel="opener" báo LIVE — bạn đã chọn lại nguy hiểm}.
Proved {Đã chứng minh}: the default protects plain anchors; rel="opener" (and window.open) bypass it {Mặc định bảo vệ anchor thường; rel="opener" (và window.open) vượt qua}.
Lab 3 — the referrer leak {Lab 3 — rò rỉ referrer}
cat > /tmp/tabnab/your.html <<'EOF'
<!doctype html><meta charset="utf-8"><title>Your site</title>
<h1>your-site.example/secret-path?token=abc123</h1>
<a href="attacker.html" target="_blank">Leaks referrer</a>
<a href="attacker.html" target="_blank" rel="noreferrer">No referrer ✓</a>
EOF
Open http://localhost:5000/your.html?token=abc123. The first link’s destination prints document.referrer = your full URL (token and all); the noreferrer link prints an empty string {Link đầu in document.referrer = URL đầy đủ (cả token); link noreferrer in chuỗi rỗng}.
Proved {Đã chứng minh}: noopener alone still leaks the URL; noreferrer closes that too {Chỉ noopener vẫn lộ URL; noreferrer đóng nốt}.
Clean up {Dọn dẹp}
kill %1 2>/dev/null; rm -rf /tmp/tabnab # stop the server + remove files
Defenses {Phòng thủ}
Defense 1 — make every external link safe explicitly {Phòng thủ 1 — làm mọi link ngoài an toàn tường minh}
Don’t rely on the default; spell it out so it’s correct on every browser, webview, and code path {Đừng dựa vào mặc định; ghi rõ để đúng trên mọi trình duyệt, webview, code path}:
<a href="https://partner.com" target="_blank" rel="noopener noreferrer">Partner</a>
// the window.open equivalent — the feature string, not rel
window.open("https://partner.com", "_blank", "noopener,noreferrer");
Use noreferrer for untrusted/external destinations; keep just noopener if you intentionally need analytics referrers {Dùng noreferrer cho đích không tin cậy/bên ngoài; giữ noopener nếu cố ý cần referrer cho analytics}.
Defense 2 — sever the opener at the page level with COOP {Phòng thủ 2 — cắt opener ở cấp trang bằng COOP}
A response header on your page isolates it from anything it opens (and anything that opens it), so cross-origin tabs never get a live window.opener {Một response header trên trang bạn cô lập nó khỏi mọi thứ nó mở (và mở nó), nên tab cross-origin không bao giờ có window.opener sống}:
Cross-Origin-Opener-Policy: same-origin
COOP is defense-in-depth (it also helps enable cross-origin isolation), but it does not replace rel on the links themselves {COOP là phòng thủ nhiều lớp (còn giúp bật cross-origin isolation), nhưng không thay rel trên chính link}.
Defense 3 — harden user-generated and dynamic links {Phòng thủ 3 — cứng hóa link động & do người dùng tạo}
- Configure your Markdown/rich-text renderer to add
rel="noopener noreferrer"(andnofollow ugc) to every external link {Cấu hình renderer Markdown/rich-text tự thêmrel="noopener noreferrer"cho mọi link ngoài}. - Audit anywhere you call
window.openwith untrusted URLs, and never addrel="opener"to links you don’t control {Rà mọi nơi gọiwindow.openvới URL không tin cậy, và đừng bao giờ thêmrel="opener"cho link bạn không kiểm soát}. - In React/JSX there is no auto-fix —
target="_blank"renders a raw anchor, so add therelyourself (a lint rule likereact/jsx-no-target-blankenforces it) {Trong React/JSX không có tự sửa — tự thêmrel; rule lintreact/jsx-no-target-blankép buộc}.
Defense 4 — keep secrets out of URLs {Phòng thủ 4 — không để secret trong URL}
Because the referrer leaks the full URL by default, never put tokens, session IDs, or PII in query strings; combine with a site-wide Referrer-Policy (e.g. strict-origin-when-cross-origin) {Vì referrer mặc định lộ URL đầy đủ, đừng để token/session/PII trong query; kết hợp Referrer-Policy toàn site}.
How this relates to earlier parts {Liên hệ các phần trước}
Reverse tabnabbing is not stopped by a script-blocking CSP (Part 3) — no script runs on your origin; a cross-origin tab merely navigates yours {Reverse tabnabbing không bị CSP (Phần 3) chặn — không script nào chạy trên origin bạn}. It is a cousin of clickjacking & framing (Part 7): both abuse the window/navigation relationship rather than injecting code, and both end in a convincing phishing surface {Nó là họ hàng của clickjacking & framing (Phần 7): đều lạm dụng quan hệ cửa sổ/điều hướng thay vì chèn code}. The blast radius shrinks further with the auth lessons from Part 5 (so a phished password alone isn’t game over) {Thiệt hại giảm thêm nhờ bài auth ở Phần 5}.
Prevention checklist {Checklist phòng tránh}
- Add
rel="noopener noreferrer"to everytarget="_blank"link; don’t rely on the browser default {Thêmrel="noopener noreferrer"cho mọi linktarget="_blank"}. - For
window.open, pass"noopener,noreferrer"in the features string — the default does not cover it {Vớiwindow.open, truyền"noopener,noreferrer"}. - Never add
rel="opener"to links you don’t fully control {Đừng thêmrel="opener"cho link không kiểm soát}. - Make your Markdown/rich-text renderer emit safe
relon external links automatically {Cho renderer tự phátrelan toàn}. - Add
Cross-Origin-Opener-Policy: same-originas defense-in-depth {Thêm COOP làm phòng thủ nhiều lớp}. - Keep tokens/PII out of URLs and set a strict
Referrer-Policy{Không để token/PII trong URL; đặtReferrer-Policychặt}. - Enforce it in CI with a lint rule (e.g.
react/jsx-no-target-blank) {Ép buộc trong CI bằng rule lint}.
Bài tập / Exercises
1. A teammate says “modern browsers fixed tabnabbing, so we can drop rel from our links.” Give two concrete cases in your codebase where that’s wrong {Đồng đội nói “trình duyệt hiện đại đã sửa, bỏ rel được”. Nêu hai trường hợp cụ thể sai}.
Solution {Lời giải}
(1) Any window.open(url, '_blank') call — it does not imply noopener, so the opened page keeps a live window.opener {window.open không ngầm noopener}. (2) Any link with rel="opener", plus old in-app webviews/embedded browsers that never shipped the 2021 default — and the referrer still leaks unless you add noreferrer {Link rel="opener", webview cũ, và referrer vẫn lộ nếu thiếu noreferrer}.
2. In the simulator, set method = window.open() with no tokens and a modern browser. Why is it still vulnerable, and which single token fixes it? {Trong simulator, chọn window.open() không token, trình duyệt hiện đại. Vì sao vẫn dính, và token nào sửa?}
Solution {Lời giải}
The implicit noopener only applies to anchors, not window.open, so the destination receives a live window.opener and can navigate your tab {Mặc định ngầm chỉ cho anchor, không cho window.open}. Fix: pass noopener (e.g. window.open(url, '_blank', 'noopener')) — or noreferrer, which implies it {Sửa: truyền noopener — hoặc noreferrer (ngầm cả noopener)}.
3. Explain why a script-blocking CSP does nothing against reverse tabnabbing, but COOP does {Giải thích vì sao CSP chặn script vô dụng với reverse tabnabbing, còn COOP thì được}.
Solution {Lời giải}
No script executes on your origin — the attacker’s script runs on its own origin and only performs a navigation of your window via window.opener.location, which CSP doesn’t govern {Không script nào chạy trên origin bạn — script kẻ tấn công chạy trên origin của nó, chỉ điều hướng cửa sổ bạn qua window.opener.location, ngoài tầm CSP}. Cross-Origin-Opener-Policy: same-origin severs the opener relationship itself, so there is no window.opener to abuse {COOP cắt chính quan hệ opener, nên không còn window.opener để lạm dụng}.
Stretch {Nâng cao}: Configure the simulator so the destination is fully blocked and no referrer leaks, using the fewest controls. Then find a configuration that blocks navigation but still leaks the referrer, and name the missing token {Cấu hình simulator để đích bị chặn hoàn toàn và không lộ referrer với ít control nhất. Rồi tìm cấu hình chặn điều hướng nhưng vẫn lộ referrer, và nêu token còn thiếu}.
Key takeaways {Điểm chính}
- Reverse tabnabbing = a page you open uses
window.openerto navigate your original tab to a phishing look-alike — a cross-origin write the same-origin policy allows {trang bạn mở dùngwindow.openerđể điều hướng tab gốc sang trang phishing}. - Modern browsers imply
noopeneron anchortarget="_blank"— butwindow.open(),rel="opener", old webviews, and user content still expose it {Trình duyệt hiện đại ngầmnoopenercho anchor — nhưngwindow.open(),rel="opener", webview cũ, nội dung người dùng vẫn lộ}. - Always set
rel="noopener noreferrer"(and the matchingwindow.openfeatures);noreferreralso stops the URL leak {Luôn đặtrel="noopener noreferrer";noreferrerchặn cả rò URL}. - Add
Cross-Origin-Opener-Policy: same-originas a page-level backstop, and keep secrets out of URLs {Thêm COOP làm lớp chặn cấp trang, và không để secret trong URL}. - CSP doesn’t stop it; COOP and correct
reldo {CSP không chặn; COOP vàrelđúng thì chặn}.
Sources {Nguồn}
- MDN —
rel="noopener"andrel="noreferrer". - Mathias Bynens — About
rel=noopener(the canonical write-up + 2021 default update). - Can I use —
target="_blank"impliesrel="noopener". - web.dev / MDN — Cross-Origin-Opener-Policy (COOP).
- OWASP — Reverse Tabnabbing.
The series {Series}
Bonus parts now sit on top of the core ten and the advanced track (Parts 11–16): Part 17 — npm install supply chain (build layer), Part 18 — reverse tabnabbing (link layer), and Part 19 — clipboard & autofill (interaction layer) {Các phần bonus giờ nằm trên mười phần lõi và nhánh nâng cao}. The same boundary lesson from Part 10 holds: a relationship you hand out — an opener, a token, a URL — is a capability; grant it deliberately, revoke it by default {Bài học biên từ Phần 10: một quan hệ bạn trao đi — opener, token, URL — là một quyền năng; cấp có chủ đích, mặc định thu hồi}.