Là gì
Clickjacking là khi trang của bạn bị nhúng vào một iframe trong suốt trên trang của kẻ tấn công, xếp phía trên một nội dung mồi. Người dùng nghĩ họ bấm vào nút trên trang kẻ tấn công, nhưng cú bấm thật đi vào trang của bạn — với đầy đủ phiên đăng nhập của họ.
Vì sao bạn quan tâm
Đây là lỗ hổng có bản vá một dòng, và đó chính là lý do nó vẫn tồn tại: một dòng header không nằm trong code của ai cả, nên không ai sở hữu nó. Nó nằm ở tầng cấu hình — và tầng cấu hình không xuất hiện trong code review.
Ba điều đáng nắm:
- Nó không cần lỗi nào khác. Không XSS, không CSRF token bị vòng qua. Người dùng thật thực hiện một hành động thật trên trang của bạn — chỉ là họ không biết mình đang làm gì.
- CSRF token không cứu được. Token nằm trong trang đã được nhúng, nên nó tới đầy đủ. Cả
SameSite=Laxcũng không: đây là một cú bấm trên chính origin của bạn. X-Frame-Optionsđã lỗi thời nhưngframe-ancestorsthì chưa ai nhớ đặt.X-Frame-Options: ALLOW-FROMchưa bao giờ được Chrome hỗ trợ, vàSAMEORIGINkhông cho phép nhúng vào partner. Nên đội nào cần nhúng thật sẽ bỏ header đi thay vì chuyển sang CSP.
Về mức thiệt hại: nó phụ thuộc hoàn toàn vào hành động một-cú-bấm mà app bạn có. Có nút "Xoá tài khoản", "Cho phép ứng dụng này truy cập" (OAuth consent), "Chuyển tiền", "Tắt xác thực hai lớp" — mỗi cái là một clickjacking đáng giá. Và nút OAuth consent là trường hợp tệ nhất: một cú bấm cấp cho kẻ tấn công quyền truy cập dài hạn.
Cơ chế hoạt động
Cơ chế dựa vào ba thuộc tính CSS, không có JavaScript nào phức tạp: opacity: 0 làm iframe vô hình, position: absolute xếp nó lên trên, và pointer-events quyết định cú bấm đi đâu.
flowchart TD A["Trang kẻ tấn công<br/>evil.example"] --> D["Nội dung mồi<br/>#quot;Bấm để nhận quà#quot;"] A --> I["iframe app.example/settings<br/>opacity: 0<br/>position: absolute<br/>z-index: 999"] I --> P["iframe nằm TRÊN nội dung mồi,<br/>và nó vô hình"] D --> C["Người dùng thấy nút mồi<br/>và bấm vào đó"] P --> C C --> R["Cú bấm đi vào IFRAME,<br/>không vào nút mồi"] R --> X["POST /settings/delete-account<br/>Cookie: session=... ✓<br/>CSRF token: ✓ (nằm trong trang đã nhúng)"]Điểm cốt lõi ở bước cuối: CSRF token tới đầy đủ. Nó nằm trong chính trang đã được nhúng, và cú bấm phát ra từ origin của bạn. Đây là lý do mọi bản vá CSRF đều không chạm tới clickjacking.
Bốn biến thể, và chúng cần biết vì bản vá frame-ancestors đóng ba trong bốn:
| Biến thể | Cách làm | Ghi chú |
|---|---|---|
| Cơ bản | iframe opacity: 0 xếp trên nút mồi | frame-ancestors đóng |
| Con trỏ theo chuột | Nút mồi di chuyển theo con trỏ, nên bấm ở đâu cũng trúng | frame-ancestors đóng |
| Nhiều bước | Chuỗi nhiều iframe, mỗi cú bấm một bước của luồng | frame-ancestors đóng |
| Kéo-thả / copy-paste | Nạn nhân kéo một chuỗi vào form trong iframe | frame-ancestors đóng, nhưng cần cả X-Frame-Options cho trình duyệt cũ |
Và biến thể mà frame-ancestors KHÔNG đóng: không phải iframe. window.open cộng một popup định vị chính xác, hoặc lợi dụng một tính năng chia sẻ màn hình — nhưng cả hai đều cần nhiều tương tác hơn và ít khả thi hơn hẳn. Về thực tế, frame-ancestors cộng X-Frame-Options đóng gần như toàn bộ bề mặt.
Mô tả sơ đồ: Sơ đồ cho thấy trang của kẻ tấn công chứa hai lớp. Lớp dưới là nội dung mồi với một nút mời bấm để nhận quà. Lớp trên là một iframe nạp trang settings của app thật, đặt opacity 0 nên vô hình, position absolute và z-index cao nên nằm trên nội dung mồi. Người dùng thấy nút mồi và bấm vào đó, nhưng cú bấm thật đi vào iframe. Request POST xoá tài khoản được gửi kèm cookie phiên và kèm cả CSRF token, vì token nằm trong chính trang đã được nhúng.
Ví dụ cụ thể
Trang tấn công đầy đủ. Bốn dòng CSS là toàn bộ kỹ thuật.
<!doctype html><style> #decoy { position: absolute; top: 300px; left: 200px; font-size: 20px; } #target { position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; /* vô hình */ z-index: 999; } /* nằm trên nội dung mồi */</style> <div id="decoy">🎁 Bấm vào đây để nhận voucher 500k</div> <!-- Trang thật, nạp với phiên đăng nhập của nạn nhân. --><!-- top/left tinh chỉnh để nút "Xoá tài khoản" nằm đúng dưới con trỏ. --><iframe id="target" src="https://app.example/settings/danger"></iframe># Kiểm một trang có nhúng được hay không — một lệnh, chạy được ngay bây giờ.curl -sI https://app.example/settings/danger | grep -iE 'x-frame-options|content-security-policy'(không có gì) ← nhúng được, và mọi hành động một-cú-bấm trên trang này bị clickjack# Sau khi vá:Content-Security-Policy: frame-ancestors 'none'X-Frame-Options: DENYVà biến thể theo con trỏ — nó xoá bỏ lập luận "phải canh toạ độ chính xác nên khó khai thác":
<script> // Nút mồi đi theo chuột, nên nạn nhân bấm ở BẤT KỲ đâu cũng trúng đúng // vị trí nút thật trong iframe. Không cần biết trước độ phân giải màn hình. addEventListener("mousemove", (e) => { const d = document.getElementById("decoy"); d.style.left = (e.clientX - 40) + "px"; d.style.top = (e.clientY - 10) + "px"; });</script>// ── Razor layout ────────────────────────────────────────────────────────────// ❌ Frame-busting bằng JavaScript. Đã bị phá từ 2010, và tác hại lớn nhất của nó// không phải là bị vòng qua — mà là nó TẠO CẢM GIÁC đã vá, nên không ai đặt header.//// Vòng qua: <iframe sandbox="allow-forms"> chặn top.location, nên dòng dưới ném// lỗi và iframe ở lại nguyên vẹn.@section Scripts { <script> if (top !== self) { top.location = self.location; } </script>} // ── Controller ──────────────────────────────────────────────────────────────public class SettingsController : Controller{ [HttpGet("/settings/danger")] public IActionResult Danger() { // ❌ Đặt header theo TỪNG action. Mặc-định-MỞ: action thứ 41 sẽ thiếu, và // trang 404 với trang OAuth consent thì không đi qua đây bao giờ. Response.Headers["X-Frame-Options"] = "SAMEORIGIN"; return View(); } // ❌ Và action này thiếu hẳn — không ai nhận ra vì nó không làm gì hỏng. [HttpGet("/oauth/authorize")] public IActionResult Authorize([FromQuery] string clientId) => View();} // ❌ Hành động một-cú-bấm, không xác nhận gì. Đây là thứ biến "nhúng được" thành// "mất tài khoản" — và nó là phần mà header không chạm tới.[HttpDelete("/api/account")]public async Task<IActionResult> DeleteAccount(CancellationToken ct){ await _users.SoftDeleteAsync(_currentUser.UserId, ct); return NoContent();}Chuyện đã xảy ra
Adobe Flash Player settings manager, 2008 (Robert Hansen & Jeremiah Grossman). Trang cấu hình Flash được nhúng trong một iframe trong suốt, và một chuỗi cú bấm bật được quyền truy cập webcam và microphone của nạn nhân. Đây là trường hợp làm cho từ "clickjacking" ra đời, và nó vẫn là ví dụ rõ nhất cho luận điểm khối 2: không có lỗi nào khác cần thiết — nạn nhân bấm thật, trên trang thật.
Likejacking trên Facebook, 2010–2012. Nút Like được nhúng trong suốt trên các trang "xem video này", nên mỗi cú bấm phát tán nội dung sang bạn bè của nạn nhân. Quy mô là điểm đáng nhớ: nó lan theo hàm số mũ giống stored XSS, mà không có một dòng JavaScript nào chạy trên origin của Facebook.
Và hình dạng đáng lo nhất hiện nay: OAuth consent. Nhiều báo cáo bug bounty mô tả trang consent (/oauth/authorize) thiếu frame-ancestors, nên một cú bấm cấp cho ứng dụng của kẻ tấn công quyền truy cập dài hạn vào tài khoản nạn nhân. Đây là trường hợp tệ nhất vì hậu quả không phải một hành động — nó là một access token.
Cách phòng chống
`frame-ancestors` trên MỌI response HTML, đặt ở một chỗ duy nhất
bắt buộcBản vá là một dòng, và cái khó không phải viết nó mà là đảm bảo nó có trên mọi response — kể cả trang lỗi, trang redirect trung gian, và một endpoint mới thêm tuần sau.
Content-Security-Policy: frame-ancestors 'none'X-Frame-Options: DENYCả hai, không phải một: frame-ancestors là chuẩn hiện tại và nó thắng khi cả hai có mặt, nhưng X-Frame-Options vẫn cần cho trình duyệt cũ và một số WebView. Chúng không xung đột.
Chọn giá trị theo nhu cầu thật:
'none'— mặc định nên chọn. Không ai nhúng được, kể cả chính bạn.'self'— khi app tự nhúng trang của mình (một modal iframe, một preview).https://partner.example— liệt kê tường minh, đây là chỗX-Frame-Optionskhông làm được (ALLOW-FROMchưa bao giờ được Chrome hỗ trợ) và là lý do phải chuyển sang CSP thay vì bỏ header đi.
Đặt ở một chỗ: một middleware, không phải một attribute trên từng controller. Cùng lý do như AutoValidateAntiforgeryToken ở topic csrf — bảo vệ theo từng endpoint là mặc-định-MỞ, và endpoint thứ 41 sẽ thiếu. Nếu có reverse proxy, đặt ở đó cộng ở app: app phải tự bảo vệ được khi được deploy sau một proxy khác.
/// <summary>/// Header chống nhúng, đặt ở MỘT chỗ cho MỌI response HTML.////// Một middleware chứ không một attribute trên từng action, và lý do giống hệt/// AutoValidateAntiforgeryToken ở topic csrf: bảo vệ theo từng endpoint là/// mặc-định-MỞ, nên action thứ 41 sẽ thiếu. Và quan trọng hơn — trang 404, trang/// lỗi 500, và các redirect trung gian KHÔNG đi qua action nào cả, nhưng chúng vẫn/// là trang nhúng được.////// Đặt cả ở đây VÀ ở reverse proxy: app phải tự bảo vệ được khi có ngày nó được/// deploy sau một proxy khác, hoặc chạy trực tiếp trong một môi trường test./// </summary>public sealed class FrameProtectionMiddleware(RequestDelegate next){ public async Task InvokeAsync(HttpContext ctx) { // OnStarting, không đặt trực tiếp: đặt trực tiếp ở đây thì response đã bắt // đầu ghi (một trang lỗi, một stream) sẽ ném InvalidOperationException, và // lúc đó middleware thành nguyên nhân của một sự cố thay vì một bản vá. ctx.Response.OnStarting(() => { var h = ctx.Response.Headers; // frame-ancestors là chuẩn hiện tại và nó THẮNG khi cả hai có mặt. // 'none' là mặc định nên chọn: không ai nhúng được, kể cả chính ta. // // Nếu có nhu cầu nhúng thật (partner, widget): liệt kê tường minh ở đây. // Đây là chỗ X-Frame-Options không làm được — ALLOW-FROM chưa bao giờ // được Chrome hỗ trợ, và đó là lý do người ta BỎ header đi thay vì // chuyển sang CSP. h.Append("Content-Security-Policy", "frame-ancestors 'none'"); // X-Frame-Options vẫn cần: trình duyệt cũ và một số WebView không hiểu // CSP. Hai header không xung đột — cái nào hiểu được thì áp cái đó. h["X-Frame-Options"] = "DENY"; return Task.CompletedTask; }); await next(ctx); }} // Program.cs — đặt SỚM trong pipeline để nó phủ cả trang lỗi do các middleware// sau ném ra. Đặt sau UseExceptionHandler thì đúng trang lỗi lại không có header.app.UseMiddleware<FrameProtectionMiddleware>();app.UseExceptionHandler("/error");app.UseStaticFiles();app.MapControllers(); // ── Lớp 1b · phá "một cú bấm" cho hành động không hồi phục được ─────────────/// <summary>/// Bản vá này không phụ thuộc header, nên nó là bản vá duy nhất còn đứng khi header/// bị một reverse proxy gỡ đi, hoặc khi trang nạp trong một WebView bỏ qua CSP.////// Clickjacking điều khiển được CON TRỎ, không điều khiển được BÀN PHÍM. Nên yêu/// cầu gõ một thứ là biện pháp trực tiếp — và nó chỉ áp cho hành động không hồi/// phục được hoặc cấp quyền, không áp cho mọi nút: mỗi bước xác nhận là ma sát./// </summary>public record DeleteAccountRequest(string CurrentPassword, string ConfirmPhrase); [HttpDelete("/api/account")]public async Task<IActionResult> DeleteAccount( [FromBody] DeleteAccountRequest req, CancellationToken ct){ var user = await _users.GetAsync(_currentUser.UserId, ct) ?? throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND); if (!_hasher.Verify(user.PasswordHash, req.CurrentPassword)) throw new ApplicationGeneralException(IdentityErrorsList.INVALID_PASSWORD); // Mẫu của GitHub: gõ một chuỗi cụ thể. Nó đòi bàn phím, nên một cú bấm bị // hijack không đi tới được đây. if (req.ConfirmPhrase != user.Handle) throw new ApplicationGeneralException(IdentityErrorsList.CONFIRMATION_MISMATCH, "Type your handle to confirm deletion"); await _users.SoftDeleteAsync(user.Id, ct); return NoContent();}Xác nhận hai bước cho hành động một-cú-bấm nguy hiểm
Bản vá này không phụ thuộc vào header, nên nó là bản vá duy nhất còn đứng khi header bị một reverse proxy nào đó gỡ đi, hoặc khi trang được nạp trong một WebView không tôn trọng CSP.
Khối 2 nói mức thiệt hại phụ thuộc vào hành động một-cú-bấm mà app có. Nên phá điều đó:
- Nhập lại mật khẩu cho xoá tài khoản, tắt MFA, đổi email (xem topic csrf lớp 3). Kẻ tấn công clickjack được cú bấm, không clickjack được việc gõ mật khẩu.
- Gõ một chuỗi để xác nhận — "gõ tên repository để xoá", mẫu của GitHub. Nó đòi bàn phím, và clickjacking chỉ điều khiển được con trỏ.
- Với OAuth consent (trường hợp tệ nhất ở khối 5): trang
/authorizephải làframe-ancestors 'none'và yêu cầu tương tác không-phải-một-cú-bấm. RFC 9700 (OAuth 2.0 Security BCP) coi việc này là bắt buộc.
Đánh đổi phải nói rõ: mỗi bước xác nhận là một bước ma sát. Nên áp nó cho hành động không hồi phục được hoặc cấp quyền, không áp cho mọi nút.
Cookie `SameSite` giới hạn cả trang đã nhúng
Chi tiết ít người biết và nó là một lớp phòng thủ thật: một iframe cross-site là một ngữ cảnh cross-site, nên cookie SameSite=Strict hoặc Lax không được gửi kèm khi trang của bạn bị nhúng từ một site khác.
Hệ quả: trang đã nhúng render ra ở trạng thái chưa đăng nhập, và clickjacking không còn gì để hijack — không có phiên, không có nút nguy hiểm nào để bấm.
Nghĩa là nếu bạn đã đặt SameSite=Lax (xem topic csrf lớp 1) thì bạn đã có một lớp chống clickjacking mà có thể bạn không biết. Nhưng không dựa vào nó một mình, vì hai lý do:
- Nó biến mất ngay khi có ai đặt
SameSite=Nonecho một widget. - Nó không bảo vệ trang không cần đăng nhập mà vẫn có hành động — một form "đăng ký nhận tin" hay một trang bình chọn.
Đây là lớp 2 vì nó là một hệ quả, không phải một biện pháp có chủ ý — và một biện pháp bạn nhận được tình cờ là một biện pháp bạn mất đi tình cờ.
# ── nginx ───────────────────────────────────────────────────────────────────# Đặt ở CẢ HAI chỗ (app và proxy), không phải chọn một. Proxy phủ được trang lỗi do# chính nginx sinh ra (502, 504) — những trang mà app không bao giờ chạm tới. Và app# phủ được trường hợp nó được deploy sau một proxy khác, hoặc chạy trực tiếp.nginx: | server { listen 443 ssl http2; server_name app.example; # always là phần quyết định: không có nó, add_header BỎ QUA mọi response # 4xx/5xx — và trang lỗi nhúng được vẫn là một trang nhúng được. add_header Content-Security-Policy "frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; location / { proxy_pass http://app:5100; # KHÔNG dùng proxy_hide_header cho hai header này: nếu app cũng đặt chúng # thì có hai giá trị, và trình duyệt lấy giá trị NGHIÊM NGẶT hơn với # frame-ancestors. Trùng lặp ở đây là an toàn, không phải một lỗi. } } # ── Trường hợp có partner nhúng thật ───────────────────────────────────────# Đây là chỗ X-Frame-Options KHÔNG làm được: ALLOW-FROM chưa bao giờ được Chrome# hỗ trợ. Nên khi có nhu cầu nhúng thật, cách đúng là liệt kê trong frame-ancestors# và BỎ X-Frame-Options cho đúng những route đó — không phải bỏ cả hai header.partner_embed: | # Chỉ áp cho route được nhúng, không áp toàn site. location /embed/ { add_header Content-Security-Policy "frame-ancestors https://partner.example https://app.example" always; # X-Frame-Options cố tình KHÔNG đặt ở đây: DENY sẽ chặn partner, và # SAMEORIGIN cũng vậy. Trình duyệt cũ không nhúng được — chấp nhận được, # vì phương án khác là bỏ bảo vệ cho toàn bộ site. proxy_pass http://app:5100; } # ── Kiểm sau mỗi deploy ────────────────────────────────────────────────────verify: | B=https://app.example FAIL=0 # /not-a-real-page và /error trong danh sách là có ý: chúng đi đường khác và # thường là chỗ duy nhất còn thiếu header. for p in / /login /settings /settings/danger /oauth/authorize /not-a-real-page /error; do H=$(curl -sI "$B$p" | tr -d '\r') echo "$H" | grep -qiE 'content-security-policy:.*frame-ancestors' \ || { echo "THIẾU frame-ancestors: $p"; FAIL=1; } echo "$H" | grep -qi 'x-frame-options' \ || echo " (cảnh báo) thiếu X-Frame-Options: $p" done exit $FAILĐừng dùng frame-busting bằng JavaScript
Đây là một lớp mà nội dung của nó là đừng làm, và nó đáng có mặt vì frame-busting vẫn là thứ người ta viết đầu tiên.
// KHÔNG dùng. Nó đã bị phá từ 2010 và nó vẫn xuất hiện trong code mới.if (top !== self) top.location = self.location;Vòng qua được bằng nhiều cách, và mỗi cách chỉ là một dòng:
<iframe sandbox="allow-forms allow-scripts">— sandbox chặntop.location, nên câu lệnh trên ném lỗi và iframe ở lại.onbeforeunloadở trang cha để huỷ điều hướng.- Đếm số lần điều hướng (
window.locationbị chặn sau 200 lần trong một số trình duyệt). - Nhúng lồng hai lớp iframe.
Và nó có một tác hại riêng: nó tạo cảm giác đã vá. Một trang có frame-busting trông như đã xử lý clickjacking, nên không ai đặt header — và header là bản vá thật.
Nếu buộc phải hỗ trợ một trình duyệt rất cũ không có cả X-Frame-Options, mẫu duy nhất còn dùng được là ẩn nội dung theo mặc định bằng CSS rồi hiện nó bằng JS khi xác nhận không bị nhúng — nhưng đó là một trang không hoạt động khi tắt JS.
Kiểm chứng đã vá
Topic này có phép kiểm rẻ nhất trong toàn bộ catalogue: một lệnh curl, và nó chạy được trên production ngay bây giờ. Điều khiến nó vẫn tồn tại không phải khó kiểm, mà là không ai kiểm.
1. Quét header trên MỌI đường dẫn quan trọng, không chỉ trang chủ. Header thường có ở trang chủ và thiếu ở trang lỗi, trang OAuth consent, hoặc một endpoint mới:
B=https://app.exampleFAIL=0for p in / /login /settings /settings/danger /oauth/authorize \ /account/delete /billing /not-a-real-page; do H=$(curl -sI "$B$p" | tr -d '\r') echo "$H" | grep -qiE 'content-security-policy:.*frame-ancestors' \ || { echo "THIẾU frame-ancestors: $p"; FAIL=1; } echo "$H" | grep -qi 'x-frame-options' \ || echo " (cảnh báo) thiếu X-Frame-Options cho trình duyệt cũ: $p"doneexit $FAIL/not-a-real-page trong danh sách là có ý: trang 404 thường đi qua một đường khác và không đi qua middleware — và một trang lỗi nhúng được vẫn là một trang nhúng được.
2. Test đếm: mọi response HTML phải có header. Đây là phép kiểm biến "nhớ đặt" thành "không đặt thì không merge được". Xem tab csharp / test.
3. Kiểm thật bằng trình duyệt — phép kiểm duy nhất chứng minh được nó thật sự không nhúng được:
// Playwright. Nạp một trang tự dựng có iframe trỏ tới app và khẳng định iframe rỗng.test("trang settings không nhúng được", async ({ page }) => { await page.setContent(`<iframe id="t" src="${BASE}/settings/danger"></iframe>`); const frame = page.frame({ url: /settings/ }); // Trình duyệt từ chối nạp → không có frame nào, hoặc frame rỗng. expect(frame === null || (await frame.content()) === "").toBeTruthy();});4. Grep chặn merge cho frame-busting bằng JS — nó tạo cảm giác đã vá và nó đã bị phá từ 2010:
grep -rnE 'top *!== *self|top *!= *self|self *!== *top|top\.location *=' \ --include='*.ts' --include='*.tsx' --include='*.js' src/ \ && { echo "frame-busting bằng JS — dùng frame-ancestors thay thế"; exit 1; }exit 05. Kiểm hành động nguy hiểm cần nhiều hơn một cú bấm (lớp 1b):
# Xoá tài khoản KHÔNG kèm mật khẩu phải là 4xxcurl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$B/api/account" \ -b "$COOKIES" -H "X-CSRF-Token: $T" # phải là 422, không phải 2046. Liệt kê hành động một-cú-bấm và đối chiếu. Đây là phép kiểm cần con người: đi qua UI, ghi lại mọi nút gây tác dụng không hồi phục được hoặc cấp quyền, và khẳng định từng cái có xác nhận bước hai. Danh sách đó nên nằm trong repo.
public class FrameProtectionTests : IClassFixture<ApiFixture>{ private readonly ApiFixture _fx; public FrameProtectionTests(ApiFixture fx) => _fx = fx; /// <summary> /// Danh sách này là DỮ LIỆU, và ba đường cuối là lý do test tồn tại: /// /// /oauth/authorize — trang có hành động đáng giá nhất (một cú bấm = access /// token dài hạn), và là trang hay thiếu header nhất. /// /not-a-real-page — trang 404 đi đường khác, không qua action nào. /// /error — trang 500 render sau khi exception handler chạy. /// /// Kiểm trang chủ rồi kết luận đã vá là sai lầm về phạm vi ở khối 8. /// </summary> [Theory] [InlineData("/")] [InlineData("/login")] [InlineData("/settings")] [InlineData("/settings/danger")] [InlineData("/oauth/authorize?client_id=x&redirect_uri=y")] [InlineData("/not-a-real-page")] [InlineData("/error")] public async Task Every_page_refuses_to_be_framed(string path) { var res = await _fx.Client.GetAsync(path); var csp = res.Headers.TryGetValues("Content-Security-Policy", out var v) ? string.Join("; ", v) : ""; Assert.Contains("frame-ancestors", csp, StringComparison.OrdinalIgnoreCase); Assert.Equal("DENY", res.Headers.GetValues("X-Frame-Options").Single()); } /// <summary> /// Phép ĐẾM, không phép thử: liệt kê MỌI endpoint trả HTML và khẳng định từng cái /// có header. Một trang mới thêm tuần sau tự động nằm trong phạm vi — đây là thứ /// biến "nhớ đặt header" thành "không đặt thì không merge được", và nó cho một /// dòng header vốn không nằm trong code của ai một chủ sở hữu. /// </summary> [Fact] public async Task No_html_endpoint_is_missing_the_header() { var htmlRoutes = _fx.Services.GetRequiredService<EndpointDataSource>().Endpoints .OfType<RouteEndpoint>() .Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") == true) // Chỉ route không có tham số: route có {id} cần dữ liệu để gọi được. .Where(e => !e.RoutePattern.RawText!.Contains('{')) .Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/')) .Distinct() .ToList(); var missing = new List<string>(); foreach (var route in htmlRoutes) { var res = await _fx.Client.GetAsync(route); if (res.Content.Headers.ContentType?.MediaType != "text/html") continue; if (!res.Headers.TryGetValues("Content-Security-Policy", out var v) || !string.Join(";", v).Contains("frame-ancestors", StringComparison.OrdinalIgnoreCase)) missing.Add(route); } Assert.Empty(missing); } /// <summary> /// Frame-busting bằng JS đã bị phá từ 2010, và tác hại chính là nó tạo cảm giác /// đã vá. Test này chặn nó quay lại. /// </summary> [Fact] public void No_javascript_frame_busting_in_the_codebase() { var offenders = Directory .EnumerateFiles("src", "*.*", SearchOption.AllDirectories) .Where(f => f.EndsWith(".ts") || f.EndsWith(".tsx") || f.EndsWith(".cshtml")) .Where(f => !f.Contains("node_modules")) .Where(f => Regex.IsMatch(File.ReadAllText(f), @"tops*!==?s*self|selfs*!==?s*top|top.locations*=")) .ToList(); Assert.Empty(offenders); } /// <summary> /// Lớp 1b — clickjacking điều khiển con trỏ, không điều khiển bàn phím. Test này /// khẳng định hành động không hồi phục được cần cả hai thứ phải GÕ. /// </summary> [Theory] [InlineData(null, "bob")] // thiếu mật khẩu [InlineData("correct-horse", null)] // thiếu chuỗi xác nhận [InlineData("correct-horse", "wrong-handle")] // chuỗi sai public async Task Account_deletion_needs_typed_confirmation(string? password, string? phrase) { var req = new HttpRequestMessage(HttpMethod.Delete, "/api/account") { Content = JsonContent.Create(new { currentPassword = password, confirmPhrase = phrase }), }; var res = await _fx.ClientAs(_fx.Bob).SendAsync(req); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); // Và tài khoản còn nguyên — khẳng định ở tầng dữ liệu, không ở status code. Assert.False((await _fx.GetUserFromDbAsync(_fx.Bob.Id)).IsDeleted); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Frame-busting bằng JavaScript | Bị phá từ 2010: sandbox chặn top.location, onbeforeunload huỷ điều hướng. Và tệ hơn: nó tạo cảm giác đã vá nên không ai đặt header |
Chỉ X-Frame-Options: SAMEORIGIN | Đã lỗi thời, và ALLOW-FROM chưa bao giờ được Chrome hỗ trợ. Cần frame-ancestors cho trường hợp có partner |
Chỉ frame-ancestors | Trình duyệt cũ và một số WebView không hiểu CSP. Đặt cả hai; chúng không xung đột |
| Đặt header ở trang chủ | Trang lỗi, trang OAuth consent, và endpoint mới thường đi đường khác. Header phải ở MỘT middleware, áp cho mọi response |
| Đặt header chỉ ở reverse proxy | App phải tự bảo vệ khi được deploy sau một proxy khác. Đặt ở cả hai |
frame-ancestors trong thẻ <meta> | frame-ancestors bị bỏ qua trong <meta> — nó chỉ có hiệu lực qua HTTP header |
| Dựa vào CSRF token | Token nằm TRONG trang đã nhúng, nên nó tới đầy đủ. Cú bấm phát ra từ chính origin của bạn |
| Coi nó "low" vì cần lừa người dùng | Nút OAuth consent bị clickjack cấp một access token dài hạn. Đó không phải low |
| Cho rằng phải canh toạ độ nên khó khai thác | Biến thể theo con trỏ ở khối 4 làm nạn nhân bấm ở đâu cũng trúng |
Sai lầm về sở hữu, và nó là nguyên nhân thật của việc lỗi này vẫn tồn tại: bản vá là một dòng header, nên nó không nằm trong code của ai. Developer nghĩ đó là việc của infra, infra nghĩ đó là việc của app. Cách sửa là một test trong CI — nó cho việc đó một chủ sở hữu.
Sai lầm về phạm vi: kiểm trang chủ rồi kết luận đã vá. Đường thật gần như luôn là một trang mà middleware không chạm tới: trang lỗi, một redirect trung gian, một trang legacy, hoặc trang consent — chính trang có hành động đáng giá nhất.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…