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.
Part 7 of 10 in the Web Security for Frontend Devs series {Phần 7/10 trong series Web Security for Frontend Devs}. Previous {Trước}: Part 6 — CORS Explained · Next {Tiếp}: Part 8 — Secure Headers & HTTPS/TLS.
This is Part 7 of a 10-part series on the web-security essentials every frontend developer should know — and actively prevent {Đâ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}. Each part explains a real threat, shows vulnerable code, then the fix, and ends with exercises {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}.
Part 1 taught you that SOP does not block cross-origin embedding — including <iframe> {Phần 1 dạy SOP không chặn nhúng cross-origin — kể cả <iframe>}. Clickjacking (also UI redressing) is what happens when an attacker exploits that gap: your authenticated app renders inside their page, invisible to the victim, while the victim clicks what they think is a harmless decoy {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}. The click is real — it just lands on the wrong control {Cú click thật — chỉ trượt vào control sai}.
What clickjacking is {Clickjacking là gì?}
Clickjacking overlays your site in a transparent or nearly invisible <iframe> on top of a decoy page {Clickjacking phủ trang bạn trong <iframe> trong suốt hoặc gần như vô hình trên trang mồi}. The victim sees “Win a prize” or a fake play button; underneath, your real Transfer, Delete account, or Like button sits in the same screen position {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}.
Common variants {Biến thể phổ biến}:
- Likejacking — tricking users into liking/sharing content they never saw {lừa user like/chia sẻ nội dung họ không hề thấy}.
- Cursorjacking — moving or faking the cursor so clicks miss what the user believes they are targeting {di chuyển hoặc giả con trỏ để click không trúng thứ user nghĩ}.
- Multistep / drag-and-drop redressing — longer interaction chains where each step is misaligned {chuỗi tương tác nhiều bước, mỗi bước lệch khung}.
The attack does not need XSS on your origin and does not need to read your DOM from evil.com {Tấn công không cần XSS trên origin bạn và không cần đọc DOM từ evil.com}. It abuses embedding + the victim’s existing session {Nó lạm dụng nhúng + phiên sẵn có của nạn nhân}.
Why it works {Vì sao hiệu quả}
When evil.com embeds https://bank.com/transfer in an iframe {Khi evil.com nhúng https://bank.com/transfer trong iframe}:
- The browser loads and renders your page inside the frame — same as a normal visit {Trình duyệt nạp và render trang bạn trong frame — như ghé bình thường}.
- The victim’s
bank.comcookies apply (session is live) {Cookiebank.comcủa nạn nhân có hiệu lực (phiên đang sống)}. - A pointer event on the iframe delivers a genuine click on your UI — not a forged request from attacker’s JS reading your data {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}.
From Part 1: SOP blocks evil.com from reading the iframe’s DOM, but does not stop evil.com from hosting the iframe and receiving clicks through it {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}. This is the same send / embed side of the model that CSRF (Part 4) and CORS read rules (Part 6) sit next to {Đâ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}.
Mental model {Mô hình tư duy}: Clickjacking is misdirected intent — the user meant to click the decoy; the browser faithfully executed a click on your origin instead {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}.
Attacker setup (conceptual) {Thiết lập phía attacker (khái niệm)}
The decoy stacks a visible button under an opacity: 0 iframe aligned to your real control {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>
Defense is not “hide the button better on the client” — it is refuse to be framed by untrusted ancestors {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}.
Defense 1: CSP frame-ancestors (modern, recommended) {Phòng thủ 1: CSP frame-ancestors (hiện đại, khuyến nghị)}
The frame-ancestors directive in Content-Security-Policy (Part 3) controls which origins may embed your document in a frame {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}. Unlike frame-src (what you may embed), frame-ancestors protects your page from being embedded elsewhere {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}.
Block all framing (admin panels, banking, settings) {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'
Allow only same-origin (most apps that never need external embeds) {Chỉ same-origin (đa số app không cần embed ngoài)}:
Content-Security-Policy: frame-ancestors 'self'; …
Allow known partners (widgets, oEmbed, partner dashboards) {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; …
Browsers that enforce CSP Level 2+ treat frame-ancestors as the authoritative framing control {Trình duyệt hỗ trợ CSP Level 2+ coi frame-ancestors là kiểm soát framing có thẩm quyền}. Put it on every HTML response for sensitive apps, including error pages {Đặt trên mọi phản hồi HTML của app nhạy cảm, kể cả trang lỗi}. Pair with the rest of your policy from Part 3 — frame-ancestors does not replace script-src {Kết hợp phần còn của policy Phần 3 — frame-ancestors không thay script-src}.
Defense 2: X-Frame-Options (legacy, still useful) {Phòng thủ 2: X-Frame-Options (legacy, vẫn hữu ích)}
X-Frame-Options is the older HTTP header with the same goal — limit who can frame you {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 | Effect {Hiệu ứng} |
|---|---|
DENY | No framing, anywhere {Không frame, mọi nơi} |
SAMEORIGIN | Only same origin may frame {Chỉ cùng origin frame được} |
ALLOW-FROM https://partner.example is deprecated and removed from modern browsers — do not rely on it; use frame-ancestors with an explicit host list instead {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}.
Ship both frame-ancestors and X-Frame-Options during transition: CSP for capable browsers, XFO for older ones {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ũ}. If they disagree, prefer the stricter effective policy and fix the config drift {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}.
Part 8 — Secure Headers & HTTPS/TLS covers how to set these consistently at the CDN, reverse proxy, and framework layer {Phần 8 nói cách set nhất quán ở CDN, reverse proxy, và framework}.
Response headers only — not <meta> {Chỉ header phản hồi — không <meta>}
X-Frame-Options in a <meta http-equiv="X-Frame-Options"> tag is ignored by spec and practice {X-Frame-Options trong thẻ <meta http-equiv="X-Frame-Options"> bị bỏ qua theo spec và thực tế}. frame-ancestors via <meta> is also unreliable compared to the Content-Security-Policy response header — treat framing policy as infrastructure owned by the server, not something frontend drops into index.html alone {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" />
Your Astro/static host must emit headers on document navigations (HTML), not only on API JSON {Host Astro/tĩnh phải phát header trên điều hướng document (HTML), không chỉ API JSON}.
Legacy fallback: JavaScript frame-busting {Legacy: frame-busting bằng JS}
Before headers were ubiquitous, pages used frame-busting {Trước khi header phổ biến, trang dùng frame-busting}:
if (window.top !== window.self) {
window.top.location = window.self.location;
}
Bypasses include sandbox without allow-top-navigation, parent onbeforeunload races, double-nested iframes, and JS disabled — headers still apply; script does not {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}. Use frame-busting only as a last-resort belt, never the primary control {Chỉ dùng frame-busting như dây an toàn cuối, không phải lớp chính}.
When you want to be embedded {Khi bạn muốn được nhúng}
Payment widgets, maps, comment embeds, and partner dashboards require framing by design {Widget thanh toán, bản đồ, embed comment, dashboard đối tác cần framing theo thiết kế}. Scope frame-ancestors to an explicit allowlist — not 'unsafe-inline'-style wildcards on ancestors {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; …
Document the contract: which routes may be framed, which partners rotate domains, and who updates the allowlist when marketing adds a new subdomain {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}. Separate “embeddable” routes (/embed/checkout) from full admin shells (/settings) with different header profiles {Tách route “embeddable” (/embed/checkout) khỏi shell admin (/settings) với profile header khác nhau}.
Your side: embedding third parties safely {Phía bạn: nhúng bên thứ ba an toàn}
When you put untrusted or semi-trusted content in <iframe>, sandbox reduces what the embedded document can do {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>
Omit allow-top-navigation and allow-popups unless the product truly needs them {Bỏ allow-top-navigation và allow-popups trừ khi sản phẩm thật sự cần}. allow attribute (Permissions Policy) further restricts camera, mic, fullscreen, payment, etc. {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 trades isolation for widget function — only add when required {allow-same-origin đổi cô lập lấy chức năng widget — chỉ bật khi cần}. When parent and child coordinate via postMessage, never trust event.data without event.origin (and ideally event.source) checks {Khi parent/child dùng postMessage, đừng tin event.data nếu chưa kiểm event.origin (và nên event.source)}:
Hardened listener {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`);
});
Sending side — pass an explicit targetOrigin, not '*', when the message carries state or PII {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);
}
Never use targetOrigin: '*' for secrets; prefer one-time codes exchanged server-side for auth handoffs {Không targetOrigin: '*' cho secret; auth handoff nên mã một lần đổi phía server}. See Part 3 — CSP for policy composition, Part 6 — CORS for read boundaries, Part 8 — Headers & TLS for deploying headers at the edge {Xem Phần 3 cho CSP, Phần 6 cho biên đọc, Phần 8 cho header ở edge}.
Frontend checklist {Checklist frontend}
Before you ship a logged-in UI {Trước khi ship UI đã đăng nhập}:
- Sensitive HTML responses send
frame-ancestors 'none'(or tight allowlist) and matchingX-Frame-Options{Phản hồi HTML nhạy cảm gửiframe-ancestors 'none'(hoặc allowlist chặt) vàX-Frame-Optionskhớp}. - Framing policy is set on the server/CDN, not only in client-side meta tags {Policy framing set trên server/CDN, không chỉ thẻ meta client}.
- Third-party
<iframe>usessandbox+ minimalallow{<iframe>bên thứ ba cósandbox+allowtối thiểu}. - Every
messagelistener validatesevent.origin(and message shape);postMessagetargets use explicit origins {Mọi listenermessagevalidateevent.origin(và shape message);postMessagedùng origin rõ}. - Embed routes are narrow (
/embed/...) with different headers than admin {Route embed hẹp (/embed/...) với header khác admin}. - Frame-busting script, if any, is non-primary {Frame-busting, nếu có, là không phải lớp chính}.
Bài tập / Exercises
1. In one sentence, explain why SOP does not stop clickjacking, and what frame-ancestors 'none' changes {Một câu: vì sao SOP không chặn clickjacking, và frame-ancestors 'none' đổi gì}.
Solution {Lời giải}
SOP blocks evil.com from reading your iframe DOM but allows embedding it; the victim’s clicks still reach your real controls with their cookies {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' tells the browser refuse to render your page inside any ancestor frame, so the overlay attack has no framed UI to click {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. Your product needs https://partner.example to embed /embed/report but /admin must never be frameable. Sketch the header strategy (two routes or two policies) {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)}.
Solution {Lời giải}
/admin (and default app shell): Content-Security-Policy: frame-ancestors 'none' + X-Frame-Options: DENY {/admin (và shell mặc định): frame-ancestors 'none' + X-Frame-Options: DENY}. /embed/report: frame-ancestors https://partner.example (+ 'self' if same-site previews) and omit DENY on that route only; document partner domain rotation {/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}. Enforce at reverse proxy or framework route groups, not one global weak policy {Ép tại reverse proxy hoặc nhóm route framework, không một policy global yếu}.
3. Fix the listener: require origin https://app.example.com, ignore messages from window.opener, and narrow the type guard {Sửa listener: bắt origin https://app.example.com, bỏ qua message từ window.opener, thu hẹp type guard}.
Solution {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
});Stretch {Nâng cao}: Research why ALLOW-FROM was removed and how frame-ancestors fixes the same use case. When would you still send X-Frame-Options: SAMEORIGIN if CSP is already frame-ancestors 'self'? {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'?}
Solution {Lời giải}
ALLOW-FROM was inconsistently implemented and never worked in WebKit/Chrome the way authors expected; frame-ancestors is standardized in CSP and supports multiple hosts in one directive {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}. Keep X-Frame-Options: SAMEORIGIN alongside frame-ancestors 'self' for older user agents that read XFO but not CSP framing — redundant on modern browsers, cheap insurance during migration {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}.
Key takeaways {Điểm chính}
- Clickjacking misdirects real clicks through invisible iframes — no XSS required {Clickjacking lệch click thật qua iframe vô hình — không cần XSS}.
frame-ancestors(CSP) is the modern, precise control; scope'none','self', or partner allowlists {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-Optionsremains useful for legacy browsers;ALLOW-FROMis dead — use CSP instead {X-Frame-Optionsvẫn hữu ích cho trình duyệt cũ;ALLOW-FROMđã chết — dùng CSP}.- Framing policy must be HTTP response headers, not meta tags {Policy framing phải là header phản hồi HTTP, không phải meta}.
- JS frame-busting is bypassable — headers first {Frame-busting JS bị vượt — header trước}.
- When you embed others:
sandbox+ minimalallow; when you talk across frames:postMessagewith origin checks and explicittargetOrigin{Khi nhúng người khác:sandbox+allowtối thiểu; khi giao tiếp frame:postMessagekiểm origin vàtargetOriginrõ}.
Next up {Tiếp theo}
Part 8 — Secure Headers & HTTPS/TLS: putting frame-ancestors, HSTS, X-Content-Type-Options, Referrer-Policy, and the rest of the header stack into production — at the CDN, reverse proxy, and framework {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}. Continue to Part 8 — Secure Headers & HTTPS/TLS.