Web Security for Frontend Devs · Part 14 — postMessage & Cross-Window Exploits
Advanced track: how cross-window messaging goes wrong — missing or substring origin checks, postMessage-to-DOM-XSS, and targetOrigin "*" leaks to popups — and the exact-origin allowlist that fixes it. With a simulator and exercises.
Part 14 — Advanced track in the Web Security for Frontend Devs series {Phần 14 — Nhánh nâng cao trong series Web Security for Frontend Devs}. Previous {Trước}: Part 13 — Strict CSP & CSP Bypasses · Next {Tiếp}: Part 15 — JWT & Token Attacks.
Part 7 and Part 10 gave the one-line rule: check event.origin, send with an explicit targetOrigin {Phần 7 và Phần 10 đưa quy tắc một dòng: kiểm event.origin, gửi với targetOrigin rõ}. This advanced part shows how that rule is broken in practice — the weak checks that look correct but aren’t, the way an innocent message becomes XSS, and the leaks that happen on the sending side {Phần nâng cao này cho thấy quy tắc đó bị phá thế nào trong thực tế — các kiểm tra yếu trông đúng nhưng sai, cách một message vô hại thành XSS, và rò rỉ ở phía gửi}.
window.postMessage is the only sanctioned channel that crosses the origin boundary (Part 1). That makes every listener a public API exposed to any window that has a handle to yours — iframes, popups, openers {window.postMessage là kênh duy nhất được phép vượt biên origin. Điều đó khiến mỗi listener là API công khai cho bất kỳ cửa sổ nào cầm handle tới bạn — iframe, popup, opener}.
The receiving side — four ways the origin check fails {Phía nhận — bốn cách kiểm origin thất bại}
Failure 1 — no origin check at all {Lỗi 1 — không kiểm origin}
// ❌ any site that can reference this window can drive this handler
window.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
document.getElementById('out').innerHTML = data.html; // sink!
});
If your page is ever embedded, opened, or opens another window, any origin can postMessage to it {Nếu trang bạn từng bị nhúng, mở, hay mở cửa sổ khác, bất kỳ origin nào cũng postMessage được tới nó}. With no event.origin gate, the attacker fully controls data {Không có cổng event.origin, kẻ tấn công kiểm soát hoàn toàn data}.
Failure 2 — substring / prefix origin checks {Lỗi 2 — kiểm origin bằng substring / prefix}
This is the most common real bug — checks that feel safe but match attacker domains {Đây là lỗi thực tế phổ biến nhất — kiểm trông an toàn nhưng khớp domain kẻ tấn công}:
// ❌ all of these are bypassable
if (event.origin.includes('app.example.com')) { … } // https://app.example.com.evil.com
if (event.origin.indexOf('example.com') !== -1) { … } // https://example.com.evil.com
if (event.origin.startsWith('https://app.example')) { … }// https://app.example.evil.com
if (/app\.example\.com/.test(event.origin)) { … } // unanchored regex → same problem
https://app.example.com.evil.com contains app.example.com, starts with the prefix, and matches the unanchored regex — yet it is an attacker origin {https://app.example.com.evil.com chứa chuỗi, bắt đầu bằng prefix, và khớp regex không neo — nhưng là origin kẻ tấn công}.
Failure 3 — message data flows into a sink {Lỗi 3 — data message chảy vào sink}
Even with an origin check, treating event.data as trusted re-introduces Part 2 XSS {Kể cả có kiểm origin, coi event.data là tin cậy lại tạo XSS Phần 2}:
// ❌ origin checked, but data is dangerous
window.addEventListener('message', (event) => {
if (event.origin !== 'https://widget.example.com') return;
eval(event.data.callback); // JS sink
location.href = event.data.redirect; // open-redirect / javascript: sink
el.innerHTML = event.data.markup; // HTML sink
});
A compromised or malicious trusted sender — or one with its own XSS — now pivots straight into your origin {Một sender tin cậy bị chiếm hoặc độc — hoặc đang dính XSS — giờ nhảy thẳng vào origin bạn}.
Failure 4 — not checking event.source for a confused deputy {Lỗi 4 — không kiểm event.source → confused deputy}
If your handler performs a privileged action (proxying an API call, returning a token), an origin check alone lets any frame from a trusted origin trigger it — including a trusted-origin page the attacker framed {Nếu handler làm hành động đặc quyền (proxy API, trả token), chỉ kiểm origin cho phép bất kỳ frame nào từ origin tin cậy kích hoạt — kể cả trang origin-tin-cậy mà kẻ tấn công frame}. Verify the message comes from the specific window you expect (event.source === iframe.contentWindow or === window.parent) {Xác minh message đến từ đúng cửa sổ bạn mong}.
The sending side — targetOrigin: '*' leaks {Phía gửi — rò targetOrigin: '*'}
postMessage(data, '*') tells the browser: deliver this to the target window no matter what origin it currently hosts {postMessage(data, '*') bảo trình duyệt: giao cho cửa sổ đích bất kể nó đang ở origin nào}. The classic leak {Rò rỉ kinh điển}:
// you open a popup for OAuth and post a token to it
const popup = window.open('https://auth.example.com/login');
// ... later ...
popup.postMessage({ token }, '*'); // ❌
Between open and your postMessage, the popup can be navigated (by a redirect, or because it was attacker-controlled) to https://evil.example {Giữa open và postMessage, popup có thể bị điều hướng sang https://evil.example}. With '*', the browser delivers the token to whatever origin currently occupies that window — the attacker {Với '*', trình duyệt giao token cho origin đang chiếm cửa sổ đó — kẻ tấn công}. Always pass the exact expected origin so the browser drops the message if the target navigated away {Luôn truyền đúng origin mong đợi để trình duyệt bỏ message nếu đích đã đổi}:
popup.postMessage({ token }, 'https://auth.example.com'); // ✅ delivered only if still that origin
Mental model {Mô hình tư duy}:
targetOriginis not a hint — it is a delivery precondition the browser enforces {targetOriginkhông phải gợi ý — là điều kiện giao trình duyệt thực thi}.
Doing it right {Làm đúng}
Exact-origin allowlist + source + schema {Allowlist origin chính xác + source + schema}
const TRUSTED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
interface ResizeMsg { type: 'resize'; height: number; }
function isResizeMsg(d: unknown): d is ResizeMsg {
return (
typeof d === 'object' && d !== null &&
(d as ResizeMsg).type === 'resize' &&
typeof (d as ResizeMsg).height === 'number' &&
(d as ResizeMsg).height > 0 && (d as ResizeMsg).height < 5000
);
}
window.addEventListener('message', (event: MessageEvent) => {
// 1) exact origin match — never includes/startsWith/regex
if (!TRUSTED_ORIGINS.has(event.origin)) return;
// 2) confirm the specific window for privileged handlers
if (event.source !== frame.contentWindow) return;
// 3) validate shape — event.data is untrusted input
if (!isResizeMsg(event.data)) return;
// 4) act on typed, bounded data only — no sinks
frame.style.height = `${event.data.height}px`;
});
The four gates — exact origin, source, schema, no sink — are independent; ship all four {Bốn cổng — origin chính xác, source, schema, không sink — độc lập; làm cả bốn}.
Prefer MessageChannel for scoped capabilities {Ưu tiên MessageChannel cho khả năng có phạm vi}
Instead of a broadcast listener, hand a single MessagePort to exactly one frame during an initial trusted handshake {Thay vì listener broadcast, trao một MessagePort cho đúng một frame trong handshake tin cậy ban đầu}:
const channel = new MessageChannel();
// give port2 to the iframe with an explicit origin; keep port1
iframe.contentWindow.postMessage({ type: 'init' }, 'https://app.example.com', [channel.port2]);
channel.port1.onmessage = (e) => { /* only the holder of port2 can reach this */ };
Only the window you handed the port to can use it — no global message surface for other origins to probe {Chỉ cửa sổ bạn trao port mới dùng được — không còn bề mặt message toàn cục cho origin khác thăm dò}.
For auth handoffs, don’t move tokens through postMessage {Auth handoff: đừng đẩy token qua postMessage}
Exchange a one-time code and let the server trade it for the session (Part 5) — the token never crosses a window boundary {Đổi một mã một lần rồi để server đổi lấy phiên (Phần 5) — token không bao giờ vượt biên cửa sổ}.
Try it — postMessage origin-check simulator {Thử ngay — trình mô phỏng kiểm origin postMessage}
Choose how the receiver validates event.origin (none / includes / startsWith / exact allowlist), then fire messages from a legit app, a look-alike domain, and a subdomain — and see which are accepted and whether the payload reaches a sink {Chọn cách receiver validate event.origin (không / includes / startsWith / allowlist chính xác), rồi bắn message từ app thật, domain nhái, và subdomain — xem cái nào được nhận và payload có tới sink không}.
Open the full demo {Mở demo đầy đủ}: /tools/postmessage-demo/.
Prevention checklist {Checklist phòng tránh}
- Always check
event.originagainst an exact allowlist (Set+===) — neverincludes/startsWith/unanchored regex {Luôn kiểmevent.origintheo allowlist chính xác — không substring/regex không neo}. - For privileged handlers, also verify
event.sourceis the expected window {Với handler đặc quyền, kiểm thêmevent.source}. - Treat
event.dataas untrusted: schema-validate, never pass toinnerHTML/eval/location{Coievent.datakhông tin cậy: validate schema, không đưa vào sink}. - Send with an explicit
targetOrigin; never'*'for anything sensitive {Gửi vớitargetOriginrõ; không'*'cho dữ liệu nhạy cảm}. - Prefer
MessageChannelports over a globalmessagelistener for cross-frame APIs {Ưu tiênMessageChannelhơn listenermessagetoàn cục}. - Move auth via one-time server-exchanged codes, not tokens over
postMessage{Chuyển auth qua mã một lần đổi phía server, không token quapostMessage}.
Bài tập / Exercises
1. Why does if (event.origin.endsWith('.example.com')) accept() still let https://evil.example.com through, and what is the correct check? {Vì sao endsWith('.example.com') vẫn cho https://evil.example.com qua, và kiểm đúng là gì?}
Solution {Lời giải}
https://evil.example.com ends with .example.com, so the check passes for any subdomain of example.com — including ones an attacker can register or take over (subdomain takeover) {https://evil.example.com kết thúc bằng .example.com, nên qua được với mọi subdomain — kể cả cái kẻ tấn công đăng ký/chiếm được}. Use an exact-match allowlist: new Set(['https://app.example.com']).has(event.origin) {Dùng allowlist khớp chính xác}. Only intentionally allow specific subdomains, each listed in full {Chỉ cho phép subdomain cụ thể, liệt kê đầy đủ}.
2. A widget posts a token to its opener with opener.postMessage({token}, '*'). Describe the attack and the one-character-class fix. {Widget gửi token cho opener bằng '*'. Mô tả tấn công và cách sửa.}
Solution {Lời giải}
If the opener window was navigated to (or always was) an attacker origin, '*' delivers the token to the attacker {Nếu cửa sổ opener bị điều hướng tới (hoặc vốn là) origin kẻ tấn công, '*' giao token cho họ}. Fix: replace '*' with the exact expected origin ('https://app.example.com') so the browser refuses delivery if the target is not that origin {Sửa: thay '*' bằng origin chính xác mong đợi để trình duyệt từ chối giao nếu đích không phải origin đó}. Better still: don’t send the token at all — use a one-time code (Part 5) {Tốt hơn: đừng gửi token — dùng mã một lần (Phần 5)}.
3. You must accept messages from app.example.com and partner.com. Write a hardened listener that resizes an iframe and rejects everything else. {Phải nhận từ app.example.com và partner.com. Viết listener cứng resize iframe và từ chối còn lại.}
Solution {Lời giải}
const TRUSTED = new Set(['https://app.example.com', 'https://partner.com']);
window.addEventListener('message', (event: MessageEvent) => {
if (!TRUSTED.has(event.origin)) return;
if (event.source !== frame.contentWindow) return;
const d = event.data;
if (typeof d !== 'object' || d === null || d.type !== 'resize') return;
if (typeof d.height !== 'number' || d.height <= 0 || d.height >= 5000) return;
frame.style.height = `${d.height}px`;
});Exact origins, source check, schema validation, bounded numeric use, no sink {Origin chính xác, kiểm source, validate schema, dùng số có giới hạn, không sink}.
Stretch {Nâng cao}: In the simulator, find a payload + origin-check combination that both passes the check and reaches the XSS sink, then switch to the exact allowlist and confirm it is rejected before the sink {Trong simulator, tìm tổ hợp payload + kiểm origin vừa qua kiểm vừa tới sink XSS, rồi đổi sang allowlist chính xác và xác nhận bị từ chối trước sink}.
Key takeaways {Điểm chính}
- Every
messagelistener is a cross-origin public API — gate it or any window can call it {Mỗi listenermessagelà API công khai cross-origin — chặn nó hoặc mọi cửa sổ gọi được}. - Substring/prefix/unanchored-regex origin checks are bypassable (
app.example.com.evil.com) — use exact-match allowlists {Kiểm origin substring/prefix/regex-không-neo bị vượt — dùng allowlist khớp chính xác}. event.datais untrusted — schema-validate; never route it toinnerHTML/eval/location{event.datakhông tin cậy — validate schema; không đưa vào sink}.targetOrigin: '*'leaks to navigated/attacker windows — always send the exact origin {targetOrigin: '*'rò sang cửa sổ bị điều hướng — luôn gửi origin chính xác}.- For privileged or scoped use, verify
event.sourceand preferMessageChannelports {Với dùng đặc quyền/phạm vi, kiểmevent.sourcevà ưu tiênMessageChannel}.
Next up {Tiếp theo}
The advanced track continues with JWT & token attacks for the frontend — alg: none, algorithm confusion, weak secrets, and why the browser is the wrong place to trust a token’s claims {Nhánh nâng cao tiếp tục với tấn công JWT & token cho frontend — alg: none, nhầm thuật toán, secret yếu, và vì sao trình duyệt là nơi sai để tin claim của token}. Continue to Part 15 — JWT & Token Attacks.