深入淺出FE(十四)深入淺出websocket

CoolSummmer發表於2020-11-29

一、理論知識

1.1 引言

Websocket是一個持久化的協議 協議分為ws(80埠)協議 和wss(443埠)協議

WebSocket是一種在單個TCP連線上進行全雙工通訊的協議。WebSocket通訊協議於2011年被IETF定為標準RFC 6455,並由RFC7936補充規範。WebSocket API也被W3C定為標準。

WebSocket使得客戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向客戶端推送資料。在WebSocket API中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以建立永續性的連線,並進行雙向資料傳輸。

1.2 設計哲學

RFC草案中已經說明,WebSocket的目的就是為了在基礎上保證傳輸的資料量最少。
Websocket協議是基於Frame而非Stream的。也就是說,資料的傳輸不是像傳統的流式讀寫一樣按位元組傳送,而是採用一幀一幀的Frame,並且每個Frame都定義了嚴格的資料結構,因此所有的資訊就在這個Frame載體中。

1.3 為什麼會有 WebSocket

以前,很多網站為了實現推送技術,所用的技術都是輪詢。輪詢是在特定的的時間間隔(如每1秒),由瀏覽器對伺服器發出HTTP請求,然後由伺服器返回最新的資料給客戶端的瀏覽器。這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向伺服器發出請求,然而HTTP請求可能包含較長的頭部,其中真正有效的資料可能只是很小的一部分,顯然這樣會浪費很多的頻寬等資源。 而比較新的技術去做輪詢的效果是Comet。這種技術雖然可以雙向通訊,但依然需要反覆發出請求。而且在Comet中,普遍採用的長連結,也會消耗伺服器資源。 在這種情況下,HTML5定義了WebSocket協議,能更好的節省伺服器資源和頻寬,並且能夠更實時地進行通訊。

1.4 WebSocket 有什麼優點

開銷少、時時性高、二進位制支援完善、支援擴充套件、壓縮更優。

  • 較少的控制開銷。在連線建立後,伺服器和客戶端之間交換資料時,用於協議控制的資料包頭部相對較小。在不包含擴充套件的情況下,對於伺服器到客戶端的內容,此頭部大小隻有2至10位元組(和資料包長度有關);對於客戶端到伺服器的內容,此頭部還需要加上額外的4位元組的掩碼。相對於HTTP請求每次都要攜帶完整的頭部,此項開銷顯著減少了。
  • 更強的實時性。由於協議是全雙工的,所以伺服器可以隨時主動給客戶端下發資料。相對於HTTP請求需要等待客戶端發起請求服務端才能響應,延遲明顯更少;即使是和Comet等類似的長輪詢比較,其也能在短時間內更多次地傳遞資料。 保持連線狀態。與HTTP不同的是,Websocket需要先建立連線,這就使得其成為一種有* 狀態的協議,之後通訊時可以省略部分狀態資訊。而HTTP請求可能需要在每個請求都攜帶狀態資訊(如身份認證等)。
  • 更好的二進位制支援。Websocket定義了二進位制幀,相對HTTP,可以更輕鬆地處理二進位制內容。
  • 可以支援擴充套件。Websocket定義了擴充套件,使用者可以擴充套件協議、實現部分自定義的子協議。如部分瀏覽器支援壓縮等。
  • 更好的壓縮效果。相對於HTTP壓縮,Websocket在適當的擴充套件支援下,可以沿用之前內容的上下文,在傳遞類似的資料時,可以顯著地提高壓縮率。

1.5 websocket和http協議關係

websocket和http一樣都是基於tcp協議的傳輸 websocket和http是兩種不同的東西 客戶端要建立 websocket連結時候要在header標記一個Upgrade的HTTP請求表示請求升級 服務端返回響應101的狀態碼 完成握手以後再傳送收據就麼有http的事了。

1.6 幀Frame
WebSocket傳輸的資料都是以Frame(幀)的形式實現的,就像TCP/UDP協議中的報文段Segment。下面就是一個Frame:(以bit為單位表示)

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
 +-+-+-+-+-------+-+-------------+-------------------------------+
 |F|R|R|R| opcode|M| Payload len |    Extended payload length    |
 |I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
 |N|V|V|V|       |S|             |   (if payload len==126/127)   |
 | |1|2|3|       |K|             |                               |
 +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
 |     Extended payload length continued, if payload len == 127  |
 + - - - - - - - - - - - - - - - +-------------------------------+
 |                               |Masking-key, if MASK set to 1  |
 +-------------------------------+-------------------------------+
 | Masking-key (continued)       |          Payload Data         |
 +-------------------------------- - - - - - - - - - - - - - - - +
 :                     Payload Data continued ...                :
 + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
 |                     Payload Data continued ...                |
 +---------------------------------------------------------------+

按照RFC中的描述:

  • FIN: 1 bit

    表示這是一個訊息的最後的一幀。第一個幀也可能是最後一個。  
    %x0 : 還有後續幀  
    %x1 : 最後一幀
    
  • RSV1、2、3: 1 bit each

    除非一個擴充套件經過協商賦予了非零值以某種含義,否則必須為0
    如果沒有定義非零值,並且收到了非零的RSV,則websocket連結會失敗
    
  • Opcode: 4 bit

    解釋說明 “Payload data” 的用途/功能
    如果收到了未知的opcode,最後會斷開連結
    定義了以下幾個opcode值:
        %x0 : 代表連續的幀
        %x1 : text幀
        %x2 : binary幀
        %x3-7 : 為非控制幀而預留的
        %x8 : 關閉握手幀
        %x9 : ping幀
    %xA :  pong幀
    %xB-F : 為非控制幀而預留的
    
  • Mask: 1 bit

    定義“payload data”是否被新增掩碼
    如果置1, “Masking-key”就會被賦值
    所有從客戶端發往伺服器的幀都會被置1
    
  • Payload length: 7 bit | 7+16 bit | 7+64 bit

    “payload data” 的長度如果在0~125 bytes範圍內,它就是“payload length”,
    如果是126 bytes, 緊隨其後的被表示為16 bits的2 bytes無符號整型就是“payload length”,
    如果是127 bytes, 緊隨其後的被表示為64 bits的8 bytes無符號整型就是“payload length”
    
  • Masking-key: 0 or 4 bytes

    所有從客戶端傳送到伺服器的幀都包含一個32 bits的掩碼(如果“mask bit”被設定成1),否則為0 bit。一旦掩碼被設定,所有接收到的payload data都必須與該值以一種演算法做異或運算來獲取真實值。(見下文)
    
  • Payload data: (x+y) bytes

    它是"Extension data"和"Application data"的總和,一般擴充套件資料為空。
    
  • Extension data: x bytes

    除非擴充套件被定義,否則就是0
    任何擴充套件必須指定其Extension data的長度
    
  • Application data: y bytes

    佔據"Extension data"之後的剩餘幀的空間
    

注意:這些資料都是以二進位制形式表示的,而非ascii編碼字串

1.7 雙端互動流程

客戶端與服務端互動流程如下所示:

客戶端 - 發起握手請求 - 伺服器接到請求後返回資訊 - 連線建立成功 - 訊息互通

所以,要解決的第一個問題就是握手問題。

握手 - 客戶端

關於握手標準,在協議中有說明:

The opening handshake is intended to be compatible with HTTP-based server-side software and intermediaries, so that a single port can be used by both HTTP clients talking to that server and WebSocket clients talking to that server. To this end, the WebSocket client's handshake is an HTTP Upgrade request:

    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Origin: http://example.com
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13

In compliance with [RFC2616], header fields in the handshake may be sent by the client in any order, so the order in which different header fields are received is not significant.

WebSocket 握手時使用的並不是 WebSocket 協議,而是 HTTP 協議,握手時發出的請求可以叫做升級請求。客戶端在握手階段通過:

Upgrade: websocket
Connection: Upgrade

Connection 和 Upgrade 這兩個頭域告知服務端,要求將通訊的協議轉換為 websocket。其中 Sec-WebSocket-Version、Sec-WebSocket-Protocol 這兩個頭域表明通訊版本和協議約定, Sec-WebSocket-Key 則作為一個防止無端連線的保障(其實並沒有什麼保障作用,因為 key 的值完全由客戶端控制,服務端並無驗證機制),其他幾個頭域則與 HTTP 協議的作用一致。

握手 - 服務端

剛才只是客戶端發出一個 HTTP 請求,表明想要握手,服務端需要對資訊進行驗證,確認以後才算握手成功(連線建立成功,可以雙向通訊),然後服務端會給客戶端回覆:"小老弟你好,沒有內鬼,連線達成!"

服務端需要回復什麼內容呢?

Status Code: 101 Web Socket Protocol Handshake
Sec-WebSocket-Accept: T5ar3gbl3rZJcRmEmBT8vxKjdDo=
Upgrade: websocket
Connection: Upgrade

首先,服務端會給出狀態碼,101 狀態碼錶示伺服器已經理解了客戶端的請求,並且回覆 Connection 和 Upgrade 表示已經切換成 websocket 協議。Sec-WebSocket-Accept 則是經過伺服器確認,並且加密過後的 Sec-WebSocket-Key。

這樣,客戶端與服務端就完成了握手操作,達成一致,使用 WebSocket 協議進行通訊。

1.8 websocket請求和響應格式

(1)客戶端傳送的握手請求

 GET  /chat HTTP/1.1
 Host: XXX.com
 Connection: Upgrade
 Upgrade: websocket
 Sec-WebSocket-Protocol: chat, superchat
 Sec-WebSocket-Version: 13
 Sec-WebSocket-key: XXXX

依次介紹下

  1. GET /chat HTTP/1.1

    可以是是chat 聊天 也可以game 遊戲

  2. Connection: Upgrade Upgrade: websocket

    這告訴伺服器給升級到websocket協議

  3. Sec-WebSocket-Protocol: chat, superchat

    使用者自定義的字串 在同一個url下 不同服務的所需要的協議 比如聊天chat 也可以其他的自定義

  4. Sec-WebSocket-Version 告訴伺服器所使用的協議版本

  5. Sec-WebSocket-Key 是base64加密的字串 瀏覽器自動生成

(2)服務端響應客戶端握手請求

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

依次介紹下

  1. HTTP/1.1 101 Switching Protocols 就是返回101狀態碼
  2. Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk= 對Sec-WebSocket-key的加密 同意握手建立連結 客戶端收到 Sec-WebSocket-Accept後 將本地的Sec-WebSocket-key 編碼做一個對比來驗證

1.9 資料幀格式

1.9.1 概述

客戶端、服務端資料的交換,離不開資料幀格式的定義。因此,在實際講解資料交換之前,我們先來看下WebSocket的資料幀格式。

WebSocket客戶端、服務端通訊的最小單位是幀(frame),由1個或多個幀組成一條完整的訊息(message)。

  • 1)傳送端:將訊息切割成多個幀,併傳送給服務端;
  • 2)接收端:接收訊息幀,並將關聯的幀重新組裝成完整的訊息。

本節的重點,就是講解資料幀的格式。詳細定義可參考 RFC6455 5.2節 。

1.9.2 資料幀格式概覽

下面給出了WebSocket資料幀的統一格式。熟悉TCP/IP協議的同學對這樣的圖應該不陌生。

  • 1)從左到右,單位是位元。比如FIN、RSV1各佔據1位元,opcode佔據4位元;
  • 2)內容包括了標識、操作程式碼、掩碼、資料、資料長度等。(下一小節會展開) 

1.9.3 資料幀格式詳解

針對前面的格式概覽圖,這裡逐個欄位進行講解,如有不清楚之處,可參考協議規範,或留言交流。

_1)FIN:_1個位元。

如果是1,表示這是訊息(message)的最後一個分片(fragment),如果是0,表示不是是訊息(message)的最後一個分片(fragment)。

2)RSV1, RSV2, RSV3:各佔1個位元。

一般情況下全為0。當客戶端、服務端協商採用WebSocket擴充套件時,這三個標誌位可以非0,且值的含義由擴充套件進行定義。如果出現非零的值,且並沒有採用WebSocket擴充套件,連線出錯。

3)Opcode:4個位元。

操作程式碼,Opcode的值決定了應該如何解析後續的資料載荷(data payload)。如果操作程式碼是不認識的,那麼接收端應該斷開連線(fail the connection)。

可選的操作程式碼如下:

%x0:表示一個延續幀。當Opcode為0時,表示本次資料傳輸採用了資料分片,當前收到的資料幀為其中一個資料分片。
%x1:表示這是一個文字幀(frame)
%x2:表示這是一個二進位制幀(frame)
%x3-7:保留的操作程式碼,用於後續定義的非控制幀。
%x8:表示連線斷開。
%x9:表示這是一個ping操作。
%xA:表示這是一個pong操作。
%xB-F:保留的操作程式碼,用於後續定義的控制幀。

4)Mask:1個位元。

表示是否要對資料載荷進行掩碼操作。從客戶端向服務端傳送資料時,需要對資料進行掩碼操作;從服務端向客戶端傳送資料時,不需要對資料進行掩碼操作。

如果服務端接收到的資料沒有進行過掩碼操作,服務端需要斷開連線。

如果Mask是1,那麼在Masking-key中會定義一個掩碼鍵(masking key),並用這個掩碼鍵來對資料載荷進行反掩碼。所有客戶端傳送到服務端的資料幀,Mask都是1。

掩碼的演算法、用途在下一小節講解。

5)Payload length:資料載荷的長度,單位是位元組。為7位,或7+16位,或1+64位。

假設數Payload length === x,如果:

x為0~126:資料的長度為x位元組。
x為126:後續2個位元組代表一個16位的無符號整數,該無符號整數的值為資料的長度。
x為127:後續8個位元組代表一個64位的無符號整數(最高位為0),該無符號整數的值為資料的長度。

此外,如果payload length佔用了多個位元組的話,payload length的二進位制表達採用網路序(big endian,重要的位在前)。

6)Masking-key:0或4位元組(32位)

所有從客戶端傳送到服務端的資料幀,資料載荷都進行了掩碼操作,Mask為1,且攜帶了4位元組的Masking-key。如果Mask為0,則沒有Masking-key。

備註:載荷資料的長度,不包括mask key的長度。

7)Payload data:(x+y) 位元組

載荷資料:包括了擴充套件資料、應用資料。其中,擴充套件資料x位元組,應用資料y位元組。

擴充套件資料:如果沒有協商使用擴充套件的話,擴充套件資料資料為0位元組。所有的擴充套件都必須宣告擴充套件資料的長度,或者可以如何計算出擴充套件資料的長度。此外,擴充套件如何使用必須在握手階段就協商好。如果擴充套件資料存在,那麼載荷資料長度必須將擴充套件資料的長度包含在內。

應用資料:任意的應用資料,在擴充套件資料之後(如果存在擴充套件資料),佔據了資料幀剩餘的位置。載荷資料長度 減去 擴充套件資料長度,就得到應用資料的長度。

1.9.4 掩碼演算法

掩碼鍵(Masking-key)是由客戶端挑選出來的32位的隨機數。掩碼操作不會影響資料載荷的長度。

掩碼、反掩碼操作都採用如下演算法。

首先,假設:

original-octet-i:為原始資料的第i位元組;
transformed-octet-i:為轉換後的資料的第i位元組;
j:為i mod 4的結果;
masking-key-octet-j:為mask key第j位元組。

演算法描述為: original-octet-i 與 masking-key-octet-j 異或後,得到 transformed-octet-i。

j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j

1.10 資料傳輸

一旦WebSocket客戶端、服務端建立連線後,後續的操作都是基於資料幀的傳遞。

WebSocket根據opcode來區分操作的型別。比如0x8表示斷開連線,0x0-0x2表示資料互動。

1.10.1 資料分片

WebSocket的每條訊息可能被切分成多個資料幀。當WebSocket的接收方收到一個資料幀時,會根據FIN的值來判斷,是否已經收到訊息的最後一個資料幀。

FIN=1表示當前資料幀為訊息的最後一個資料幀,此時接收方已經收到完整的訊息,可以對訊息進行處理。FIN=0,則接收方還需要繼續監聽接收其餘的資料幀。

此外,opcode在資料交換的場景下,表示的是資料的型別。0x01表示文字,0x02表示二進位制。而0x00比較特殊,表示延續幀(continuation frame),顧名思義,就是完整訊息對應的資料幀還沒接收完。

1.10.2 資料分片例子

下面例子來自 MDN,可以很好地演示資料的分片。客戶端向服務端兩次傳送訊息,服務端收到訊息後回應客戶端,這裡主要看客戶端往服務端傳送的訊息。

第一條訊息:

FIN=1, 表示是當前訊息的最後一個資料幀。服務端收到當前資料幀後,可以處理訊息。opcode=0x1,表示客戶端傳送的是文字型別。

第二條訊息:

  • 1)FIN=0,opcode=0x1,表示傳送的是文字型別,且訊息還沒傳送完成,還有後續的資料幀;
  • 2)FIN=0,opcode=0x0,表示訊息還沒傳送完成,還有後續的資料幀,當前的資料幀需要接在上一條資料幀之後;
  • 3)FIN=1,opcode=0x0,表示訊息已經傳送完成,沒有後續的資料幀,當前的資料幀需要接在上一條資料幀之後。服務端可以將關聯的資料幀組裝成完整的訊息。
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!

1.11 連線保持、心跳

WebSocket為了保持客戶端、服務端的實時雙向通訊,需要確保客戶端、服務端之間的TCP通道保持連線沒有斷開。

然而,對於長時間沒有資料往來的連線,如果依舊長時間保持著,可能會浪費包括的連線資源。

但不排除有些場景,客戶端、服務端雖然長時間沒有資料往來,但仍需要保持連線。

這個時候,可以採用心跳來實現:

傳送方->接收方:ping
接收方->傳送方:pong

ping、pong的操作,對應的是WebSocket的兩個控制幀,opcode分別是0x9、0xA。

舉例:WebSocket服務端向客戶端傳送ping,只需要如下程式碼(採用ws模組)

ws.ping('', false, true);

1.12 Sec-WebSocket-Key/Accept的作用

前面提到了,Sec-WebSocket-Key/Sec-WebSocket-Accept 在主要作用在於提供基礎的防護,減少惡意連線、意外連線。

作用大致歸納如下:

  • 1)避免服務端收到非法的websocket連線(比如http客戶端不小心請求連線websocket服務,此時服務端可以直接拒絕連線)
  • 2)確保服務端理解websocket連線。因為ws握手階段採用的是http協議,因此可能ws連線是被一個http伺服器處理並返回的,此時客戶端可以通過Sec-WebSocket-Key來確保服務端認識ws協議。(並非百分百保險,比如總是存在那麼些無聊的http伺服器,光處理Sec-WebSocket-Key,但並沒有實現ws協議。。。)
  • 3)用瀏覽器裡發起ajax請求,設定header時,Sec-WebSocket-Key以及其他相關的header是被禁止的。這樣可以避免客戶端傳送ajax請求時,意外請求協議升級(websocket upgrade)
  • 4)可以防止反向代理(不理解ws協議)返回錯誤的資料。比如反向代理前後收到兩次ws連線的升級請求,反向代理把第一次請求的返回給cache住,然後第二次請求到來時直接把cache住的請求給返回(無意義的返回)。
  • 5)Sec-WebSocket-Key主要目的並不是確保資料的安全性,因為Sec-WebSocket-Key、Sec-WebSocket-Accept的轉換計算公式是公開的,而且非常簡單,最主要的作用是預防一些常見的意外情況(非故意的)。

強調:Sec-WebSocket-Key/Sec-WebSocket-Accept 的換算,只能帶來基本的保障,但連線是否安全、資料是否安全、客戶端/服務端是否合法的 ws客戶端、ws服務端,其實並沒有實際性的保證。

1.13 資料掩碼的作用

1.13.1 概述

WebSocket協議中,資料掩碼的作用是增強協議的安全性。但資料掩碼並不是為了保護資料本身,因為演算法本身是公開的,運算也不復雜。除了加密通道本身,似乎沒有太多有效的保護通訊安全的辦法。

那麼為什麼還要引入掩碼計算呢,除了增加計算機器的運算量外似乎並沒有太多的收益(這也是不少同學疑惑的點)。

答案還是兩個字:安全。但並不是為了防止資料洩密,而是為了防止早期版本的協議中存在的代理快取汙染攻擊(proxy cache poisoning attacks)等問題。

1.13.2 代理快取汙染攻擊

下面摘自2010年關於安全的一段講話。其中提到了代理伺服器在協議實現上的缺陷可能導致的安全問題(點此檢視出處)。

“We show, empirically, that the current version of the WebSocket consent mechanism is vulnerable to proxy cache poisoning attacks. Even though the WebSocket handshake is based on HTTP, which should be understood by most network intermediaries, the handshake uses the esoteric “Upgrade” mechanism of HTTP. In our experiment, we find that many proxies do not implement the Upgrade mechanism properly, which causes the handshake to succeed even though subsequent traffic over the socket will be misinterpreted by the proxy.”
【TALKING】 Huang, L-S., Chen, E., Barth, A., Rescorla, E., and C.
Jackson, "Talking to Yourself for Fun and Profit", 2010,

在正式描述攻擊步驟之前,我們假設有如下參與者:

  • 1)攻擊者、攻擊者自己控制的伺服器(簡稱“邪惡伺服器”)、攻擊者偽造的資源(簡稱“邪惡資源”);
  • 2)受害者、受害者想要訪問的資源(簡稱“正義資源”);
  • 3)受害者實際想要訪問的伺服器(簡稱“正義伺服器”);
  • 4)中間代理伺服器。

攻擊步驟一:

  • 1)攻擊者瀏覽器 向 邪惡伺服器 發起WebSocket連線。根據前文,首先是一個協議升級請求;
  • 2)協議升級請求 實際到達 代理伺服器;
  • 3)代理伺服器 將協議升級請求轉發到 邪惡伺服器;
  • 4)邪惡伺服器 同意連線,代理伺服器 將響應轉發給 攻擊者。

由於 upgrade 的實現上有缺陷,代理伺服器 以為之前轉發的是普通的HTTP訊息。因此,當協議伺服器 同意連線,代理伺服器 以為本次會話已經結束。

攻擊步驟二:

  • 1)攻擊者 在之前建立的連線上,通過WebSocket的介面向 邪惡伺服器 傳送資料,且資料是精心構造的HTTP格式的文字。其中包含了 正義資源的地址,以及一個偽造的host(指向正義伺服器)。(見後面報文)
  • 2)請求到達 代理伺服器 。雖然複用了之前的TCP連線,但 代理伺服器 以為是新的HTTP請求。
  • 3)代理伺服器 向 邪惡伺服器 請求 邪惡資源。
  • 4)邪惡伺服器 返回 邪惡資源。代理伺服器 快取住 邪惡資源(url是對的,但host是 正義伺服器 的地址)。

到這裡,受害者可以登場了:

  • 1)受害者 通過 代理伺服器 訪問 正義伺服器 的 正義資源。
  • 2)代理伺服器 檢查該資源的url、host,發現本地有一份快取(偽造的)。
  • 3)代理伺服器 將 邪惡資源 返回給 受害者。
  • 4)受害者 卒。

附:前面提到的精心構造的“HTTP請求報文”。

Client → Server:
POST /path/of/attackers/choice HTTP/1.1 Host: host-of-attackers-choice.com Sec-WebSocket-Key: <connection-key>
Server → Client:
HTTP/1.1 200 OK
Sec-WebSocket-Accept: <connection-key>

1.13.3 當前解決方案

最初的提案是對資料進行加密處理。基於安全、效率的考慮,最終採用了折中的方案:對資料載荷進行掩碼處理。

需要注意的是,這裡只是限制了瀏覽器對資料載荷進行掩碼處理,但是壞人完全可以實現自己的WebSocket客戶端、服務端,不按規則來,攻擊可以照常進行。

但是對瀏覽器加上這個限制後,可以大大增加攻擊的難度,以及攻擊的影響範圍。如果沒有這個限制,只需要在網上放個釣魚網站騙人去訪問,一下子就可以在短時間內展開大範圍的攻擊。

1.14 相容性

相容性列表來自MDN

二、Spring boot + Koa2 + websocket實現簡易聊天室

用Spring boot完成一個簡單的聊天室功能demo,不具備高併發、持久化等功能。

2.1 pom.xml檔案配置

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2.2 新增ServerEndpointExporter配置bean

配置類WebSocketConfig,掃描並註冊帶有@ServerEndpoint註解的所有websocket服務端

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

/**
 * @author Alan Chen
 * @description 開啟WebSocket支援
 * @date 2020-04-08
 */
@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

2.3 新增service中的類

新建WebSocketServer類,WebSocket服務端是多例的,一次WebSocket連線對應一個例項

/**
 * @Auther: karma2014
 * @Date: 2020/11/22 17:55
 * @Description: websocket的具體實現類
 * 使用springboot的唯一區別是要@Component宣告下,而使用獨立容器是由容器自己管理websocket的,
 * 但在springboot中連容器都是spring管理的。
    雖然@Component預設是單例模式的,但springboot還是會為每個websocket連線初始化一個bean,
    所以可以用一個靜態set儲存起來。
 */
@ServerEndpoint(value = "/websocket")
@Component
public class MyWebSocket {
    //用來存放每個客戶端對應的MyWebSocket物件。
    private static CopyOnWriteArraySet<MyWebSocket> webSocketSet = new CopyOnWriteArraySet<MyWebSocket>();
    //與某個客戶端的連線會話,需要通過它來給客戶端傳送資料
    private Session session;
    /**
     * 連線建立成功呼叫的方法
     */
    @OnOpen
    public void onOpen(Session session) {
        this.session = session;
        webSocketSet.add(this);     //加入set中
        System.out.println("有新連線加入!當前線上人數為" + webSocketSet.size());
        this.session.getAsyncRemote().sendText("恭喜您成功連線上WebSocket-->當前線上人數為:"+webSocketSet.size());
    }
    /**
     * 連線關閉呼叫的方法
     */
    @OnClose
    public void onClose() {
        webSocketSet.remove(this);  //從set中刪除
        System.out.println("有一連線關閉!當前線上人數為" + webSocketSet.size());
    }
    /**
     * 收到客戶端訊息後呼叫的方法
     *
     * @param message 客戶端傳送過來的訊息*/
    @OnMessage
    public void onMessage(String message, Session session) {
        System.out.println("來自客戶端的訊息:" + message);
        //群發訊息
        broadcast(message);
    }
    /**
     * 發生錯誤時呼叫
     *
     */
    @OnError
    public void onError(Session session, Throwable error) {
        System.out.println("發生錯誤");
        error.printStackTrace();
    }
    /**
     * 群發自定義訊息
     * */
    public  void broadcast(String message){
        for (MyWebSocket item : webSocketSet) {
            //this.session.getBasicRemote().sendText(message);
            item.session.getAsyncRemote().sendText(message);//非同步傳送訊息.
        }
    }
}

2.4 前端程式碼

前端程式碼可以單獨啟動一個Nodejs靜態服務,將此頁面放在靜態資料夾即可

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="UTF-8">
    <title>My WebSocket</title>
    <style>
        #message{
            margin-top:40px;
            border:1px solid gray;
            padding:20px;
        }
    </style>
</head>
<body>
<button onclick="conectWebSocket()">連線WebSocket</button>
<button onclick="closeWebSocket()">斷開連線</button>
<hr />
<br />
訊息:<input id="text" type="text" />
<button onclick="send()">傳送訊息</button>
<div id="message"></div>
</body>
<script type="text/javascript">
    var websocket = null;
    function conectWebSocket(){
        //判斷當前瀏覽器是否支援WebSocket
        if ('WebSocket'in window) {
            websocket = new WebSocket("ws://localhost:8080/websocket");
        } else {
            alert('Not support websocket')
        }
        //連線發生錯誤的回撥方法
        websocket.onerror = function() {
            setMessageInnerHTML("error");
        };
        //連線成功建立的回撥方法
        websocket.onopen = function(event) {
            setMessageInnerHTML("Loc MSG: 成功建立連線");
        }
        //接收到訊息的回撥方法
        websocket.onmessage = function(event) {
            setMessageInnerHTML(event.data);
        }
        //連線關閉的回撥方法
        websocket.onclose = function() {
            setMessageInnerHTML("Loc MSG:關閉連線");
        }
        //監聽視窗關閉事件,當視窗關閉時,主動去關閉websocket連線,防止連線還沒斷開就關閉視窗,server端會拋異常。
        window.onbeforeunload = function() {
            websocket.close();
        }
    }
    //將訊息顯示在網頁上
    function setMessageInnerHTML(innerHTML) {
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }
    //關閉連線
    function closeWebSocket() {
        websocket.close();
    }
    //傳送訊息
    function send() {
        var message = document.getElementById('text').value;
        websocket.send(message);
    }
</script>
</html>

專案啟動介面:

三、專案設計問題

3.1 介面設計

3.2 資料推送頻率

3.3 心跳檢測和斷線重連

3.4 不支援websocket的瀏覽器降級為輪詢查詢

使用一些成熟的框架,如socket.io

3.5 Nginx 部署

The WebSocket protocol is different from the HTTP protocol, but the WebSocket handshake is compatible with HTTP, using the HTTP Upgrade facility to upgrade the connection from HTTP to WebSocket.

This allows WebSocket applications to more easily fit into existing infrastructures.

For example, WebSocket applications can use the standard HTTP ports 80 and 443, thus allowing the use of existing firewall rules.

location /websocket {
    proxy_pass http://xx.xxx.xx.xx; # websocket伺服器。不用管 ws://
    proxy_http_version 1.1; # http協議切換

    proxy_set_header Host $host; # 保留源資訊
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Upgrade $http_upgrade; # 請求協議升級,如果生產環境有報400錯誤,可以嘗試將值設定為websocket
    proxy_set_header Connection $connection_upgrade;
}

3.6 叢集部署

 

四、參考資料

[1] RFC6455:websocket規範
[2] 規範:資料幀掩碼細節
[3] 規範:資料幀格式
[4] server-example
[5] 編寫websocket伺服器
[6] 對網路基礎設施的攻擊(資料掩碼操作所要預防的事情)
[7] Talking to Yourself for Fun and Profit(含有攻擊描述)
[8] What is Sec-WebSocket-Key for?
[9] 10.3. Attacks On Infrastructure (Masking)
[10] Talking to Yourself for Fun and Profit
[11] Why are WebSockets masked?
[12] How does websocket frame masking protect against cache poisoning?
[13] What is the mask in a WebSocket frame?

附錄:更多Web端即時通訊資料

SSE技術詳解:一種全新的HTML5伺服器推送事件技術
Comet技術詳解:基於HTTP長連線的Web端實時通訊技術
socket.io實現訊息推送的一點實踐及思路
LinkedIn的Web端即時通訊實踐:實現單機幾十萬條長連線
Web端即時通訊技術的發展與WebSocket、Socket.io的技術實踐
Web端即時通訊安全:跨站點WebSocket劫持漏洞詳解(含示例程式碼)
開源框架Pomelo實踐:搭建Web端高效能分散式IM聊天伺服器
使用WebSocket和SSE技術實現Web端訊息推送
詳解Web端通訊方式的演進:從Ajax、JSONP 到 SSE、Websocket
MobileIMSDK-Web的網路層框架為何使用的是Socket.io而不是Netty?
理論聯絡實際:從零理解WebSocket的通訊原理、協議格式、安全性
微信小程式中如何使用WebSocket實現長連線(含完整原始碼)
快速瞭解Electron:新一代基於Web的跨平臺桌面技術
一文讀懂前端技術演進:盤點Web前端20年的技術變遷史
Web端即時通訊基礎知識補課:一文搞懂跨域的所有問題!
Web端即時通訊實踐乾貨:如何讓你的WebSocket斷網重連更快速?
WebSocket從入門到精通,半小時就夠!