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.

Phần 14 — Nhánh nâng cao trong series Web Security for Frontend Devs. Trước: Tiếp:

Phần 7Phần 10 đưa quy tắc một dòng: kiểm event.origin, gửi với targetOrigin rõ. 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 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.


Phía nhận — bốn cách kiểm origin thất bại

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!
});

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ó. Không có cổng event.origin, kẻ tấn công kiểm soát hoàn toàn data.

Lỗi 2 — kiểm origin bằng substring / prefix

Đâ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 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.

Lỗi 3 — data message chảy vào sink

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
});

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.

Lỗi 4 — không kiểm event.source → confused deputy

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. Xác minh message đến từ đúng cửa sổ bạn mong.


Phía gửi — rò targetOrigin: '*'

postMessage(data, '*') bảo trình duyệt: giao cho cửa sổ đích bất kể nó đang ở origin nào. 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 }, '*'); // ❌

Giữa openpostMessage, popup có thể bị điều hướng sang https://evil.example. Với '*', trình duyệt giao token cho origin đang chiếm cửa sổ đó — kẻ tấn công. 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

Mô hình tư duy: targetOrigin không phải gợi ý — là điều kiện giao trình duyệt thực thi.


Làm đúng

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`;
});

Bốn cổng — origin chính xác, source, schema, không sink — độc lập; làm cả bốn.

Ưu tiên MessageChannel cho khả năng có phạm vi

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 */ };

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ò.

Auth handoff: đừng đẩy token qua postMessage

Đổ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ổ.


Thử ngay — trình mô phỏng kiểm origin postMessage

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.

Mở demo đầy đủ:


Checklist phòng tránh

  1. Luôn kiểm event.origin theo allowlist chính xác — không substring/regex không neo.
  2. Với handler đặc quyền, kiểm thêm event.source.
  3. Coi event.data không tin cậy: validate schema, không đưa vào sink.
  4. Gửi với targetOrigin; không '*' cho dữ liệu nhạy cảm.
  5. Ưu tiên MessageChannel hơn listener message toàn cục.
  6. Chuyển auth qua mã một lần đổi phía server, không token qua postMessage.

Bài tập / Exercises

1. Vì sao endsWith('.example.com') vẫn cho https://evil.example.com qua, và kiểm đúng là gì?

Lời giải

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. Dùng allowlist khớp chính xác. Chỉ cho phép subdomain cụ thể, liệt kê đầy đủ.

2. Widget gửi token cho opener bằng '*'. Mô tả tấn công và cách sửa.

Lời giải

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ọ. 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 đó. Tốt hơn: đừng gửi token — dùng mã một lần (Phần 5).

3. 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.

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`;
});

Origin chính xác, kiểm source, validate schema, dùng số có giới hạn, không sink.

Nâng cao: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.


Điểm chính

  • Mỗi listener messageAPI công khai cross-origin — chặn nó hoặc mọi cửa sổ gọi được.
  • Kiểm origin substring/prefix/regex-không-neo bị vượt — dùng allowlist khớp chính xác.
  • event.data không tin cậy — validate schema; không đưa vào sink.
  • targetOrigin: '*' sang cửa sổ bị điều hướng — luôn gửi origin chính xác.
  • Với dùng đặc quyền/phạm vi, kiểm event.source và ưu tiên MessageChannel.

Tiếp theo

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.