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 7 và Phầ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ả 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 open và postMessage, 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:
targetOriginkhô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
- Luôn kiểm
event.origintheo allowlist chính xác — không substring/regex không neo. - Với handler đặc quyền, kiểm thêm
event.source. - Coi
event.datakhông tin cậy: validate schema, không đưa vào sink. - Gửi với
targetOriginrõ; không'*'cho dữ liệu nhạy cảm. - Ưu tiên
MessageChannelhơn listenermessagetoàn cục. - 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 và 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
messagelà API 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.datakhông tin cậy — validate schema; không đưa vào sink.targetOrigin: '*'rò 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.sourcevà ưu tiênMessageChannel.
Tiếp theo
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.