jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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

evil.com — decoy page "Win a prize 🎁" invisible bank.com iframe on top victim thinks they click the button… …but click lands on "Transfer" in the iframe fix: frame-ancestors 'none' / X-Frame-Options: DENY
evil.com's decoy sits under a transparent iframe of your app — the victim's click hits your real button, not the prize

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}:

  1. 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}.
  2. The victim’s bank.com cookies apply (session is live) {Cookie bank.com của nạn nhân có hiệu lực (phiên đang sống)}.
  3. 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}.


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
ValueEffect {Hiệu ứng}
DENYNo framing, anywhere {Không frame, mọi nơi}
SAMEORIGINOnly 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-ancestorsX-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-navigationallow-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 matching X-Frame-Options {Phản hồi HTML nhạy cảm gửi frame-ancestors 'none' (hoặc allowlist chặt) X-Frame-Options khớ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> uses sandbox + minimal allow {<iframe> bên thứ ba có sandbox + allow tối thiểu}.
  • Every message listener validates event.origin (and message shape); postMessage targets use explicit origins {Mọi listener message validate event.origin (và shape message); postMessage dù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-Options remains useful for legacy browsers; ALLOW-FROM is dead — use CSP instead {X-Frame-Options vẫ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 + minimal allow; when you talk across frames: postMessage with origin checks and explicit targetOrigin {Khi nhúng người khác: sandbox + allow tối thiểu; khi giao tiếp frame: postMessage kiểm origintargetOrigin rõ}.

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.