Web Security for Frontend Devs · Part 7 — Clickjacking & Framing
Clickjacking (UI redressing) tricks users into clicking your real UI through invisible iframes. Defend with CSP frame-ancestors, X-Frame-Options, safe embedding, and postMessage hygiene — with exercises.
Phần 7/10 trong series Web Security for Frontend Devs. Trước: Tiếp:
Đây là Phần 7 của series 10 bài về những kiến thức bảo mật web mà mọi frontend dev nên biết — và chủ động phòng tránh. Mỗi phần giải thích một mối đe dọa thật, cho xem code lỗ hổng, rồi cách sửa, và kết thúc bằng bài tập.
Phần 1 dạy SOP không chặn nhúng cross-origin — kể cả <iframe>. Clickjacking (hay UI redressing) xảy ra khi kẻ tấn công lợi dụng khe hở đó: app đã đăng nhập của bạn render trong trang của họ, vô hình với nạn nhân, trong khi nạn nhân bấm thứ họ tưởng là mồi vô hại. Cú click thật — chỉ trượt vào control sai.
Clickjacking là gì?
Clickjacking phủ trang bạn trong <iframe> trong suốt hoặc gần như vô hình trên trang mồi. Nạn nhân thấy “Trúng thưởng” hay nút play giả; bên dưới, nút Chuyển tiền, Xóa tài khoản, hay Like thật của bạn nằm đúng vị trí màn hình.
Biến thể phổ biến:
- lừa user like/chia sẻ nội dung họ không hề thấy.
- di chuyển hoặc giả con trỏ để click không trúng thứ user nghĩ.
- chuỗi tương tác nhiều bước, mỗi bước lệch khung.
Tấn công không cần XSS trên origin bạn và không cần đọc DOM từ evil.com. Nó lạm dụng nhúng + phiên sẵn có của nạn nhân.
Vì sao hiệu quả
Khi evil.com nhúng https://bank.com/transfer trong iframe:
- Trình duyệt nạp và render trang bạn trong frame — như ghé bình thường.
- Cookie
bank.comcủa nạn nhân có hiệu lực (phiên đang sống). - Sự kiện con trỏ trên iframe gửi click thật lên UI của bạn — không phải request giả từ JS attacker đọc dữ liệu bạn.
Từ Phần 1: SOP chặn evil.com đọc DOM iframe, nhưng không ngăn evil.com chứa iframe và nhận click xuyên qua. Đây là cùng phía gửi / nhúng của mô hình mà CSRF (Phần 4) và quy tắc đọc CORS (Phần 6) nằm cạnh.
Mô hình tư duy: Clickjacking là ý định bị lệch — user muốn bấm mồi; trình duyệt thành thật thực thi click trên origin bạn.
Thiết lập phía attacker (khái niệm)
Mồi xếp nút hiện dưới iframe opacity: 0 căn đúng control thật của bạn:
<!-- https://evil.com — decoy button + invisible victim iframe -->
<button type="button">Claim your free gift 🎁</button>
<iframe
style="position:absolute;opacity:0;z-index:2"
src="https://bank.example/transfer?amount=10000&to=attacker"
title="hidden bank UI"
></iframe>
Phòng thủ không phải ẩn nút kỹ hơn phía client — mà từ chối bị frame bởi ancestor không tin cậy.
Phòng thủ 1: CSP frame-ancestors (hiện đại, khuyến nghị)
Directive frame-ancestors trong CSP (Phần 3) điều khiển origin nào được nhúng document của bạn trong frame. Khác frame-src (thứ bạn nhúng), frame-ancestors bảo vệ trang bạn khỏi bị nhúng nơi khác.
Chặn mọi framing (admin, ngân hàng, cài đặt):
Content-Security-Policy: frame-ancestors 'none'; default-src 'self'; script-src 'self' 'nonce-PLACEHOLDER'
Chỉ same-origin (đa số app không cần embed ngoài):
Content-Security-Policy: frame-ancestors 'self'; …
Cho phép đối tác đã biết (widget, oEmbed, dashboard đối tác):
Content-Security-Policy: frame-ancestors 'self' https://partner.example https://embed.trusted-cdn.example; …
Trình duyệt hỗ trợ CSP Level 2+ coi frame-ancestors là kiểm soát framing có thẩm quyền. Đặt trên mọi phản hồi HTML của app nhạy cảm, kể cả trang lỗi. Kết hợp phần còn của policy Phần 3 — frame-ancestors không thay script-src.
Phòng thủ 2: X-Frame-Options (legacy, vẫn hữu ích)
X-Frame-Options là header HTTP cũ cùng mục tiêu — giới hạn ai frame được bạn:
X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN
| Value | Hiệu ứng |
|---|---|
DENY | Không frame, mọi nơi |
SAMEORIGIN | Chỉ cùng origin frame được |
ALLOW-FROM đã deprecated và bị gỡ khỏi trình duyệt hiện đại — đừng tin; dùng frame-ancestors với danh sách host rõ ràng.
Gửi cả hai frame-ancestors và X-Frame-Options khi chuyển đổi: CSP cho trình duyệt mới, XFO cho cũ. Nếu mâu thuẫn, ưu tiên policy chặt hơn có hiệu lực và sửa lệch cấu hình.
Phần 8 nói cách set nhất quán ở CDN, reverse proxy, và framework.
Chỉ header phản hồi — không <meta>
X-Frame-Options trong thẻ <meta http-equiv="X-Frame-Options"> bị bỏ qua theo spec và thực tế. frame-ancestors qua <meta> cũng không tin cậy bằng header phản hồi Content-Security-Policy — coi policy framing là hạ tầng server, không chỉ frontend nhét vào index.html.
<!-- ❌ Do not rely on this for clickjacking defense -->
<meta http-equiv="X-Frame-Options" content="DENY" />
Host Astro/tĩnh phải phát header trên điều hướng document (HTML), không chỉ API JSON.
Legacy: frame-busting bằng JS
Trước khi header phổ biến, trang dùng frame-busting:
if (window.top !== window.self) {
window.top.location = window.self.location;
}
Vượt qua gồm sandbox thiếu allow-top-navigation, race onbeforeunload, iframe lồng kép, tắt JS — header vẫn hiệu lực; script thì không. Chỉ dùng frame-busting như dây an toàn cuối, không phải lớp chính.
Khi bạn muốn được nhúng
Widget thanh toán, bản đồ, embed comment, dashboard đối tác cần framing theo thiết kế. Giới hạn frame-ancestors bằng allowlist rõ — không wildcard kiểu 'unsafe-inline' cho ancestor:
Content-Security-Policy: frame-ancestors https://cms.partner.example https://editor.trusted.example; …
Ghi hợp đồng: route nào được frame, đối tác nào đổi domain, ai cập nhật allowlist khi marketing thêm subdomain. Tách route “embeddable” (/embed/checkout) khỏi shell admin (/settings) với profile header khác nhau.
Phía bạn: nhúng bên thứ ba an toàn
Khi bạn đặt nội dung không hoặc bán tin cậy trong <iframe>, sandbox giảm quyền document nhúng:
<iframe
src="https://widgets.thirdparty.example/chart"
sandbox="allow-scripts allow-same-origin"
title="Market chart"
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
Bỏ allow-top-navigation và allow-popups trừ khi sản phẩm thật sự cần. Thuộc tính allow (Permissions Policy) giới hạn thêm camera, mic, fullscreen, payment, v.v.:
<iframe
src="https://video.example/embed/PLACEHOLDER_ID"
allow="fullscreen"
sandbox="allow-scripts allow-same-origin"
></iframe>
allow-same-origin đổi cô lập lấy chức năng widget — chỉ bật khi cần. Khi parent/child dùng postMessage, đừng tin event.data nếu chưa kiểm event.origin (và nên event.source):
Listener cứng:
const TRUSTED_PARENT_ORIGINS = new Set([
'https://app.example.com',
'https://staging.example.com',
]);
type ResizePayload = { type: 'resize'; height: number };
function isResizePayload(data: unknown): data is ResizePayload {
if (typeof data !== 'object' || data === null) return false;
const o = data as Record<string, unknown>;
return o.type === 'resize' && typeof o.height === 'number' && o.height > 0 && o.height < 4000;
}
window.addEventListener('message', (event: MessageEvent) => {
if (!TRUSTED_PARENT_ORIGINS.has(event.origin)) return;
if (event.source !== window.parent) return;
if (!isResizePayload(event.data)) return;
document.documentElement.style.setProperty('--embed-height', `${event.data.height}px`);
});
Phía gửi — truyền targetOrigin rõ, không '*', khi message mang trạng thái hoặc PII:
const parentOrigin = 'https://app.example.com';
function notifyParentReady(): void {
window.parent.postMessage({ type: 'embed-ready' }, parentOrigin);
}
Không targetOrigin: '*' cho secret; auth handoff nên mã một lần đổi phía server. Xem Phần 3 cho CSP, Phần 6 cho biên đọc, Phần 8 cho header ở edge.
Checklist frontend
Trước khi ship UI đã đăng nhập:
- Phản hồi HTML nhạy cảm gửi
frame-ancestors 'none'(hoặc allowlist chặt) vàX-Frame-Optionskhớp. - Policy framing set trên server/CDN, không chỉ thẻ meta client.
<iframe>bên thứ ba cósandbox+allowtối thiểu.- Mọi listener
messagevalidateevent.origin(và shape message);postMessagedùng origin rõ. - Route embed hẹp (
/embed/...) với header khác admin. - Frame-busting, nếu có, là không phải lớp chính.
Bài tập / Exercises
1. Một câu: vì sao SOP không chặn clickjacking, và frame-ancestors 'none' đổi gì.
Lời giải
SOP chặn evil.com đọc DOM iframe nhưng cho nhúng; click nạn nhân vẫn tới control thật kèm cookie. frame-ancestors 'none' bảo trình duyệt từ chối render trang bạn trong mọi frame ancestor, nên tấn công phủ không còn UI thật để bấm.
2. Sản phẩm cần https://partner.example nhúng /embed/report nhưng /admin không bao giờ được frame. Phác chiến lược header (hai route hoặc hai policy).
Lời giải
/admin (và shell mặc định): frame-ancestors 'none' + X-Frame-Options: DENY. /embed/report: frame-ancestors https://partner.example /embed/report: frame-ancestors https://partner.example (+ 'self' nếu preview nội bộ) và chỉ route đó không DENY; ghi quy trình đổi domain đối tác. Ép tại reverse proxy hoặc nhóm route framework, không một policy global yếu.
3. Sửa listener: bắt origin https://app.example.com, bỏ qua message từ window.opener, thu hẹp type guard.
Lời giải
const APP_ORIGIN = 'https://app.example.com';
type EmbedPing = { type: 'ping' };
function isEmbedPing(data: unknown): data is EmbedPing {
return typeof data === 'object' && data !== null && (data as EmbedPing).type === 'ping';
}
window.addEventListener('message', (event: MessageEvent) => {
if (event.origin !== APP_ORIGIN) return;
if (event.source !== window.parent) return;
if (!isEmbedPing(event.data)) return;
// handle ping
});Nâng cao:Tìm hiểu vì sao ALLOW-FROM bị gỡ và frame-ancestors thay thế thế nào. Khi nào vẫn gửi X-Frame-Options: SAMEORIGIN nếu CSP đã frame-ancestors 'self'?
Lời giải
ALLOW-FROM triển khai không nhất, không hoạt động đúng kỳ vọng trên WebKit/Chrome; frame-ancestors chuẩn hóa trong CSP và hỗ trợ nhiều host. Giữ XFO: SAMEORIGIN cùng frame-ancestors 'self' cho client cũ đọc XFO nhưng không CSP framing — dư thừa trên trình duyệt mới, bảo hiểm rẻ khi migrate.
Điểm chính
- Clickjacking lệch click thật qua iframe vô hình — không cần XSS.
frame-ancestors(CSP) là kiểm soát hiện đại, chính xác; dùng'none','self', hoặc allowlist đối tác.X-Frame-Optionsvẫn hữu ích cho trình duyệt cũ;ALLOW-FROMđã chết — dùng CSP.- Policy framing phải là header phản hồi HTTP, không phải meta.
- Frame-busting JS bị vượt — header trước.
- Khi nhúng người khác:
sandbox+allowtối thiểu; khi giao tiếp frame:postMessagekiểm origin vàtargetOriginrõ.
Tiếp theo
Phần 8 — Secure Headers & HTTPS/TLS: đưa frame-ancestors, HSTS, X-Content-Type-Options, Referrer-Policy, và phần còn của stack header vào production — tại CDN, reverse proxy, và framework.