Bạn mở DevTools và thấy một đoạn script đang chạy trên trang mà mình chưa bao giờ viết. Đó là XSS (Cross-Site Scripting): kẻ tấn công chèn được JavaScript vào HTML của bạn, và nó chạy với đầy đủ quyền của trang — đọc cookie, đánh cắp session token, gửi dữ liệu người dùng về server của chúng. Cơ chế chèn payload và luồng tấn công Stored XSS đầy đủ (kèm ví dụ chạy thật) đã có trong bài Web Bị Hack Qua SQL và Command Injection.
Validate và encode input là tuyến phòng thủ đầu tiên, nhưng chỉ cần một nhánh validation bị sót hay một chỗ ghép HTML quên encode là payload vẫn lọt vào trang. Content Security Policy (CSP) là tuyến thứ hai, nằm sau mọi validation và ngay trong trình duyệt: một HTTP header khai báo trước trang được phép tải script từ đâu, mọi thứ ngoài danh sách đó đều bị từ chối thực thi — kể cả script đã nằm sẵn trong HTML. Bài này dựng một policy chạy được, kèm ví dụ bạn tự chạy để thấy trình duyệt chặn script.
1. Content Security Policy (CSP) là gì?
CSP là một cơ chế bảo mật cho phép bạn quy định danh sách các nguồn nội dung được phép tải: qua HTTP header (cách khuyến nghị) hoặc meta tag có tên Content-Security-Policy, trình duyệt chỉ thực thi script từ những nguồn bạn tin tưởng — trừ browser extension, vì content script của extension chạy trong “isolated world” riêng, được trình duyệt miễn trừ khỏi CSP của trang.
View Mermaid diagram code
flowchart TD
Start([Server trả response kèm CSP header]) --> Parse[Trình duyệt parse HTML và policy]
Parse --> Check{Gặp script: nguồn có trong whitelist?}
Check -->|Có: 'self', nonce, hash| Allow[Thực thi script]
Check -->|Không: inline, domain lạ| Block[Chặn, báo lỗi trong console]
style Allow fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Block fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3Với policy script-src 'self':
- Script từ
mywebsite.com/app.js→ cho phép (cùng origin) - Script inline
<script>alert('XSS')</script>→ chặn (không có nonce/hash) - Script từ
evil.com/hack.js→ chặn (không nằm trong whitelist)
2. Cách triển khai CSP trên website
Policy bạn cần phụ thuộc vào một câu hỏi duy nhất: trang có inline script hay không.
View Mermaid diagram code
flowchart TD
Start{Trang có script inline không?}
Start -->|Không| Simple["script-src 'self'"]
Start -->|Có| Inline{Tách được ra file .js?}
Inline -->|Được| Simple
Inline -->|Không: CMS, framework| Render{Trang render kiểu gì?}
Render -->|Server-rendered| Nonce[Nonce: random mỗi request]
Render -->|Static HTML| Hash[Hash: cho script cố định]
Simple --> Done([Thêm object-src + base-uri])
Nonce --> Done
Hash --> Done
style Simple fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Nonce fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Hash fill:#241832,stroke:#c08cff,stroke-width:1.5px,color:#d4b3ff2.1. Policy tối thiểu: script-src 'self'
Thêm HTTP header sau trên server:
1
Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none'script-src 'self': chỉ cho phép script tải từ cùng origin. Mọi script inline bị chặn (trừ khi có nonce hoặc hash), vàeval()/new Function()cũng bị chặn nếu không thêm'unsafe-eval'- đừng thêm.object-src 'none': chặn plugin (<object>,<embed>), một ngõ thực thi code khác.base-uri 'none': chặn kẻ tấn công chèn thẻ<base>để đổi URL gốc, khiến script dùng đường dẫn tương đối bị trỏ sang domain khác.
Không thấy default-src trong policy trên là có lý do: nó chỉ đóng vai trò fallback cho các fetch directive mà bạn không khai báo riêng (img-src, style-src, connect-src…), và không hề áp dụng cho base-uri. Nếu mục tiêu là chặn XSS thì ba directive trên đã đủ; thêm default-src 'self' sẽ siết thêm nguồn ảnh, style, font.
Chạy thử: xem trình duyệt từ chối inline script
Dựng một server Express nhỏ đặt CSP qua header. Trang trả về hai script: một inline (đại diện cho payload XSS chèn thẳng vào trang) và một external cùng origin (/app.js):
▶ Chạy thử ở máy bạn: csp/csp-inline-block.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Lưu thành `csp-inline-block.js` (`npm install express`)
const express = require("express");
const app = express();
// Chỉ cho chạy script cùng origin — cấm inline script và script từ domain lạ
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"script-src 'self'; object-src 'none'; base-uri 'none'",
);
next();
});
// Trang có 2 script: 1 inline (bị CSP chặn) và 1 cùng origin (được chạy)
app.get("/", (req, res) => {
res.type("html").send(`<!doctype html>
<p id="inline">inline script: chưa chạy</p>
<p id="external">external script: chưa chạy</p>
<script>
// Đại diện cho payload XSS chèn thẳng vào trang
document.getElementById("inline").textContent = "inline script: ĐÃ CHẠY";
</script>
<script src="/app.js"></script>`);
});
// Script cùng origin — hợp lệ với script-src 'self', nên được chạy
app.get("/app.js", (req, res) => {
res
.type("js")
.send(
`document.getElementById("external").textContent = "external script: ĐÃ CHẠY";`,
);
});
app.listen(4020);Chạy node csp-inline-block.js rồi mở http://localhost:4020/. Hai script đều “muốn chạy”, nhưng script-src 'self' chỉ cho một cái:
- Inline script - dạng mà payload XSS gần như luôn mang (
<script>…</script>,onerror="…") - bị chặn:<p id="inline">vẫn làinline script: chưa chạy. - External script cùng origin (
/app.js) chạy bình thường:<p id="external">đổi thànhexternal script: ĐÃ CHẠY.
Console in ra đúng lý do:
1
Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-GyPGuK7JiOFh/eoelEDdCEeRZ0XenrD2ruk6gF69HVo='), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.Đó chính là chỗ CSP cứu bạn: encode hay sanitize bị sót ở đâu đó, payload lọt vào HTML, nhưng vì nó là inline script nên trình duyệt vẫn từ chối thực thi.
Lưu ý: HTTP header và meta tag không tương đương
<meta http-equiv="Content-Security-Policy" content="..."> chỉ là phương án dự phòng khi bạn không sửa được header (static hosting): nó không hỗ trợ frame-ancestors, sandbox, report-to, không dùng được chế độ Report-Only, và chỉ bảo vệ phần HTML nằm sau thẻ meta.
2.2. Trang bắt buộc có inline script: nonce hoặc hash
Với CMS hay framework SSR không tách được script ra file, bạn có hai cách khai báo “inline script này là của tôi”.
1. Nonce — server sinh một chuỗi ngẫu nhiên mới cho mỗi response, đặt vào cả CSP header lẫn thuộc tính nonce của thẻ <script>; trình duyệt chỉ chạy script có nonce khớp. Sinh mới cho mỗi request bằng CSPRNG là bắt buộc: một nonce viết cứng trong HTML thì kẻ tấn công chỉ cần xem source, copy lại, và chèn <script nonce="ABC123"> là vượt qua.
Ví dụ với Express + helmet:
▶ Chạy thử ở máy bạn: csp/csp-nonce.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Lưu thành `csp-nonce.js` (`npm install express helmet ejs`)
const crypto = require("crypto");
const helmet = require("helmet");
const express = require("express");
const app = express();
app.set("view engine", "ejs");
app.use((req, res, next) => {
// Nonce mới bằng CSPRNG cho MỖI request
res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
next();
});
app.use(
helmet.contentSecurityPolicy({
directives: {
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
objectSrc: ["'none'"],
baseUri: ["'none'"],
},
}),
);
app.get("/", (req, res) => {
// Template engine chèn đúng nonce đó vào thẻ script
res.render("index", { cspNonce: res.locals.cspNonce });
});
app.listen(4021);Trong template views/index.ejs:
1
2
3
4
5
6
<!doctype html>
<p id="out">nonced inline: chưa chạy</p>
<script nonce="<%= cspNonce %>">
document.getElementById("out").textContent = "nonced inline: ĐÃ CHẠY";
console.log("Script nội tuyến hợp lệ cho request này.");
</script>Lưu hai file trên rồi chạy:
1
2
npm install express helmet ejs
node csp-nonce.js # mở http://localhost:4021/Nonce khớp nên inline script chạy: <p> đổi thành nonced inline: ĐÃ CHẠY và console in ra dòng log - ngược hẳn với mục 2.1, nơi inline không nonce bị chặn. Tải lại trang rồi xem source hai lần: giá trị nonce khác nhau mỗi lần, đúng yêu cầu “mỗi request một nonce”. Một lưu ý khi dùng helmet: nó tự thêm bộ directive mặc định an toàn của mình, nên header thực tế dài hơn ba directive bạn khai báo (có cả default-src 'self', style-src, form-action…) - phần liên quan ở đây là script-src 'self' 'nonce-<random>'.
2. Hash — trang tĩnh không có server để thay giá trị nonce ở mỗi request, nên phải khai báo hash của đúng đoạn script đó. Không cần công cụ gì: trình duyệt đã in sẵn hash trong thông báo lỗi ở mục 2.1, copy vào policy là script chạy được.
1
Content-Security-Policy: script-src 'self' 'sha256-GyPGuK7JiOFh/eoelEDdCEeRZ0XenrD2ruk6gF69HVo='; object-src 'none'; base-uri 'none'Đánh đổi: hash tính trên toàn bộ nội dung script, kể cả khoảng trắng - sửa một dấu cách là hash cũ hết hiệu lực và script bị chặn. Hash chỉ hợp với script cố định, ít thay đổi.
3. Nâng cao - 'strict-dynamic': pattern CSP hiện đại (Google strict CSP, OWASP khuyến nghị) kết hợp nonce với 'strict-dynamic':
1
Content-Security-Policy: script-src 'nonce-<random>' 'strict-dynamic'; object-src 'none'; base-uri 'none'Script đã được tin cậy (có nonce) được phép tự nạp thêm script con, và trình duyệt bỏ qua toàn bộ host whitelist - bạn không cần liệt kê từng domain CDN nữa. Nếu ứng dụng có nguy cơ DOM-based XSS (gán innerHTML, dùng eval…), tìm hiểu thêm directive require-trusted-types-for 'script' (Trusted Types).
Một trang gói gọn cả bốn tình huống của 'strict-dynamic':
▶ Chạy thử ở máy bạn: csp/csp-strict-dynamic.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// Lưu thành `csp-strict-dynamic.js` (`npm install express`)
const crypto = require("crypto");
const express = require("express");
const app = express();
app.get("/", (req, res) => {
// Nonce mới cho MỖI request — bắt buộc, không được viết cứng
const nonce = crypto.randomBytes(16).toString("base64");
res.setHeader(
"Content-Security-Policy",
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'`,
);
res.type("html").send(`<!doctype html>
<p id="nonced">1. nonced script: chưa chạy</p>
<p id="child">2. script con (do script nonce tạo ra): chưa chạy</p>
<p id="plain">3. inline không nonce: chưa chạy</p>
<p id="host">4. <script src> cùng origin, không nonce: chưa chạy</p>
<!-- 1 + 2: script có nonce chạy, VÀ tự tạo thêm script con — script con cũng chạy -->
<script nonce="${nonce}">
document.getElementById("nonced").textContent = "1. nonced script: ĐÃ CHẠY";
const s = document.createElement("script");
s.textContent =
"document.getElementById('child').textContent = '2. script con (do script nonce tạo ra): ĐÃ CHẠY';";
document.body.appendChild(s);
</script>
<!-- 3: inline không nonce -> bị chặn -->
<script>
document.getElementById("plain").textContent = "3. inline không nonce: ĐÃ CHẠY";
</script>
<!-- 4: script src cùng origin, không nonce -> vẫn bị chặn, vì 'strict-dynamic' bỏ qua host whitelist -->
<script src="/app.js"></script>`);
});
app.get("/app.js", (req, res) => {
res
.type("js")
.send(
`document.getElementById("host").textContent = "4. <script src> cùng origin, không nonce: ĐÃ CHẠY";`,
);
});
app.listen(4023);Chạy node csp-strict-dynamic.js rồi mở http://localhost:4023/. Bốn dòng dừng ở:
1
2
3
4
1. nonced script: ĐÃ CHẠY
2. script con (do script nonce tạo ra): ĐÃ CHẠY
3. inline không nonce: chưa chạy
4. <script src> cùng origin, không nonce: chưa chạy- (1) nonce khớp → chạy. Script được tin cậy vì nonce trong thẻ khớp nonce trong header.
- (2) script con thừa hưởng được sự tin cậy. Script (1) tự
document.createElement("script")rồi append — script con không có nonce nhưng vẫn chạy, vì nó do code đã tin cậy sinh ra. Đây chính là cách các loader/framework (webpack, Google Tag Manager) hoạt động mà không cần bạn liệt kê từng domain. - (3) inline không nonce → chặn. Payload XSS chèn thẳng vào HTML không đoán được nonce ngẫu nhiên.
- (4)
<script src>cùng origin, không nonce → chặn. Điểm dễ bất ngờ: bật'strict-dynamic'thì host whitelist bị bỏ qua hoàn toàn - kể cả'self'. Một thẻ<script src>viết thẳng trong HTML mà thiếu nonce vẫn bị chặn dù nguồn “đáng ra được phép”; chỉ nonce (hoặc script do code tin cậy sinh ra) mới tính.
2.3. Script từ CDN: thêm domain, và luôn kèm SRI
Cần jQuery, Chart.js… từ CDN thì phải thêm domain đó vào script-src:
1
2
3
4
5
<script
src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"
integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g=="
crossorigin="anonymous"
></script>Thuộc tính integrity (Subresource Integrity - SRI) chứa hash của file: nếu CDN bị xâm phạm và nội dung file bị đổi, hash không khớp và trình duyệt từ chối thực thi. Đây chính là lời giải cho rủi ro script third-party bị tấn công ngay trên CDN, và crossorigin="anonymous" là điều kiện để SRI hoạt động với file cross-origin.
Vậy là bạn đã có cách chặn script lạ chạy trên website. Lấy script-src 'self'; object-src 'none'; base-uri 'none' làm nền; tách được inline script ra file .js thì tốt nhất, còn nếu buộc phải giữ inline thì dùng nonce + 'strict-dynamic' - và tuyệt đối tránh 'unsafe-inline', vì nó bật lại đúng thứ CSP sinh ra để chặn. Dùng thư viện từ CDN thì luôn kèm SRI (integrity) để CDN có bị xâm phạm cũng không nạp nổi code lạ. Nhưng nhớ rằng CSP chỉ là lớp phòng thủ bổ sung: phải kết hợp với cookie HttpOnly/Secure/SameSite và validate input chặt chẽ mới ngăn được cả XSS lẫn session hijacking - và nó cũng chỉ là một mắt xích trong bức tranh injection lớn hơn, xem nó phối hợp với chống SQL Injection, Command Injection và file upload ra sao trong bài Web Bị Hack Qua SQL và Command Injection.