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.
Phần 18 — Nhánh bonus trong series Web Security for Frontend Devs. Trước: Tiếp:
Bạn thêm một link hết sức bình thường. 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. Họ quay lại, thấy giao diện quen, và gõ mật khẩu. Đó là reverse tabnabbing, và cả vụ tấn công dựa trên một thuộc tính: window.opener.
Đây là lỗi frontend mà dev nào cũng ship ít nhất một lần. 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.
Mô hình tư duy cốt lõi
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" ┘
Quy tắc then chốt, đáng ngạc nhiên: 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";
}
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.
Vì sao nó thường không nổ hôm nay
Năm 2021 spec HTML đổi để target="_blank" trên link ngầm định rel="noopener". Với noopener, window.opener của trang mới là null. Có từ Chrome/Edge 88, Firefox 79, Safari 12.1. Vậy một anchor thường trên trình duyệt hiện đại là an toàn mặc định.
Mặc định chỉ phủ một trong các cách bạn mở tab.
Nơi nó vẫn cắn
noopener ngầm áp dụng cho anchor target="_blank" — không phải mọi thứ:
window.open()không ngầmnoopener. Window trả về vẫn giữopenersống trừ khi bạn truyền feature.rel="opener"bật lại opener trên anchor, ghi đè mặc định. Người ta thêm lại để “sửa” popup hỏng và vô tình mở lại lỗ.- Trình duyệt cũ / webview nhúng — vài in-app browser và engine cũ chưa có mặc định ngầm.
- Nội dung do người dùng tạo — 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.
Còn một rò rỉ âm thầm sống sót mặc định: 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ộ.
Phân biệt then chốt:
noopenercắt liên kết script/điều hướng;noreferrercòn bỏ Referer (và ngầmnoopener).
Thử ngay — trình mô phỏng reverse tabnabbing
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. 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. Hoàn toàn mô phỏng; không mở cửa sổ thật.
Mở demo đầy đủ:
Lab thực hành — tái hiện an toàn
Chứng minh cơ chế trong trình duyệt thật. Trang “phishing” ở đây chỉ hiện banner — không trộm gì.
Phục vụ qua HTTP để
window.openernhất quán (originfile://thô khác nhau giữa trình duyệt).
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() 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
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. Giờ thử “noopener” — null, nút tấn công vô hại.
Đã chứng minh: window.open() không có mặc định hiện đại; phải truyền 'noopener'.
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
Link thường báo null trên trình duyệt hiện đại. Link rel="opener" báo LIVE — bạn đã chọn lại nguy hiểm.
Đã chứng minh: Mặc định bảo vệ anchor thường; rel="opener" (và window.open) vượt qua.
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
Link đầu in document.referrer = URL đầy đủ (cả token); link noreferrer in chuỗi rỗng.
Đã chứng minh: noopener Chỉ noopener vẫn lộ URL; noreferrer đóng nốt.
Dọn dẹp
kill %1 2>/dev/null; rm -rf /tmp/tabnab # stop the server + remove files
Phòng thủ
Phòng thủ 1 — làm mọi link ngoài an toàn tường minh
Đừ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");
Dùng noreferrer cho đích không tin cậy/bên ngoài; giữ noopener nếu cố ý cần referrer cho analytics.
Phòng thủ 2 — cắt opener ở cấp trang bằng COOP
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 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.
Phòng thủ 3 — cứng hóa link động & do người dùng tạo
- Cấu hình renderer Markdown/rich-text tự thêm
rel="noopener noreferrer"cho mọi link ngoài. - Rà mọi nơi gọi
window.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. - Trong React/JSX không có tự sửa — tự thêm
rel; rule lintreact/jsx-no-target-blankép buộc.
Phòng thủ 4 — không để secret trong URL
Vì referrer mặc định lộ URL đầy đủ, đừng để token/session/PII trong query; kết hợp Referrer-Policy toàn site.
Liên hệ các phần trước
Reverse tabnabbing không bị CSP (Phần 3) chặn — không script nào chạy trên origin bạn. 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. Thiệt hại giảm thêm nhờ bài auth ở Phần 5.
Checklist phòng tránh
- Thêm
rel="noopener noreferrer"cho mọi linktarget="_blank". - Với
window.open, truyền"noopener,noreferrer". - Đừng thêm
rel="opener"cho link không kiểm soát. - Cho renderer tự phát
relan toàn. - Thêm COOP làm phòng thủ nhiều lớp.
- Không để token/PII trong URL; đặt
Referrer-Policychặt. - Ép buộc trong CI bằng rule lint.
Bài tập / Exercises
1. Đồ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.
Lời giải
window.open không ngầm noopener. Link rel="opener", webview cũ, và referrer vẫn lộ nếu thiếu noreferrer.
2. 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?
Lời giải
Mặc định ngầm chỉ cho anchor, không cho window.open. Sửa: truyền noopener — hoặc noreferrer (ngầm cả noopener).
3. Giải thích vì sao CSP chặn script vô dụng với reverse tabnabbing, còn COOP thì được.
Lời giải
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 COOP cắt chính quan hệ opener, nên không còn window.opener để lạm dụng.
Nâng cao: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.
Điểm chính
- trang bạn mở dùng
window.openerđể điều hướng tab gốc sang trang phishing. - Trình duyệt hiện đại ngầm
noopenercho anchor — nhưngwindow.open(),rel="opener", webview cũ, nội dung người dùng vẫn lộ. - Luôn đặt
rel="noopener noreferrer";noreferrerchặn cả rò URL. - Thêm COOP làm lớp chặn cấp trang, và không để secret trong URL.
- CSP không chặn; COOP và
relđúng thì chặn.
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.
Series
Các phần bonus giờ nằm trên mười phần lõi và nhánh nâng cao. 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.