jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 7Phầ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ả 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 openpostMessage, 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}: targetOrigin is not a hint — it is a delivery precondition the browser enforces {targetOrigin khô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}

  1. Always check event.origin against an exact allowlist (Set + ===) — never includes/startsWith/unanchored regex {Luôn kiểm event.origin theo allowlist chính xác — không substring/regex không neo}.
  2. For privileged handlers, also verify event.source is the expected window {Với handler đặc quyền, kiểm thêm event.source}.
  3. Treat event.data as untrusted: schema-validate, never pass to innerHTML/eval/location {Coi event.data không tin cậy: validate schema, không đưa vào sink}.
  4. Send with an explicit targetOrigin; never '*' for anything sensitive {Gửi với targetOrigin; không '*' cho dữ liệu nhạy cảm}.
  5. Prefer MessageChannel ports over a global message listener for cross-frame APIs {Ưu tiên MessageChannel hơn listener message toàn cục}.
  6. 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 qua postMessage}.

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 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 message listener is a cross-origin public API — gate it or any window can call it {Mỗi listener messageAPI 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.data is untrusted — schema-validate; never route it to innerHTML/eval/location {event.data không tin cậy — validate schema; không đưa vào sink}.
  • targetOrigin: '*' leaks to navigated/attacker windows — always send the exact origin {targetOrigin: '*' 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.source and prefer MessageChannel ports {Với dùng đặc quyền/phạm vi, kiểm event.source và ưu tiên MessageChannel}.

Next up {Tiếp theo}

The advanced track continues with JWT & token attacks for the frontendalg: 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 frontendalg: 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.