WebSocket:5分鐘從入門到精通

阿里CBU前端團隊招人,感興趣的小夥伴加我微信casperchen,加以前附錄本身的博客或者github地址,簡歷可直接發416394284@qq.com 👈javascript

1、內容概覽

WebSocket的出現,使得瀏覽器具有了實時雙向通訊的能力。本文由淺入深,介紹了WebSocket如何創建鏈接、交換數據的細節,以及數據幀的格式。此外,還簡要介紹了針對WebSocket的安全攻擊,以及協議是如何抵禦相似攻擊的。html

2、什麼是WebSocket

HTML5開始提供的一種瀏覽器與服務器進行全雙工通信的網絡技術,屬於應用層協議。它基於TCP傳輸協議,並複用HTTP的握手通道。前端

對大部分web開發者來講,上面這段描述有點枯燥,其實只要記住幾點:java

  1. WebSocket能夠在瀏覽器裏使用
  2. 支持雙向通訊
  3. 使用很簡單

一、有哪些優勢

說到優勢,這裏的對比參照物是HTTP協議,歸納地說就是:支持雙向通訊,更靈活,更高效,可擴展性更好。git

  1. 支持雙向通訊,實時性更強。
  2. 更好的二進制支持。
  3. 較少的控制開銷。鏈接建立後,ws客戶端、服務端進行數據交換時,協議控制的數據包頭部較小。在不包含頭部的狀況下,服務端到客戶端的包頭只有2~10字節(取決於數據包長度),客戶端到服務端的的話,須要加上額外的4字節的掩碼。而HTTP協議每次通訊都須要攜帶完整的頭部。
  4. 支持擴展。ws協議定義了擴展,用戶能夠擴展協議,或者實現自定義的子協議。(好比支持自定義壓縮算法等)

對於後面兩點,沒有研究過WebSocket協議規範的同窗可能理解起來不夠直觀,但不影響對WebSocket的學習和使用。github

二、須要學習哪些東西

對網絡應用層協議的學習來講,最重要的每每就是鏈接創建過程數據交換教程。固然,數據的格式是逃不掉的,由於它直接決定了協議自己的能力。好的數據格式能讓協議更高效、擴展性更好。web

下文主要圍繞下面幾點展開:ajax

  1. 如何創建鏈接
  2. 如何交換數據
  3. 數據幀格式
  4. 如何維持鏈接

3、入門例子

在正式介紹協議細節前,先來看一個簡單的例子,有個直觀感覺。例子包括了WebSocket服務端、WebSocket客戶端(網頁端)。完整代碼能夠在 這裏 找到。算法

這裏服務端用了ws這個庫。相比你們熟悉的socket.iows實現更輕量,更適合學習的目的。express

一、服務端

代碼以下,監聽8080端口。當有新的鏈接請求到達時,打印日誌,同時向客戶端發送消息。當收到到來自客戶端的消息時,一樣打印日誌。

var app = require('express')();
var server = require('http').Server(app);
var WebSocket = require('ws');

var wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', function connection(ws) {
    console.log('server: receive connection.');
    
    ws.on('message', function incoming(message) {
        console.log('server: received: %s', message);
    });

    ws.send('world');
});

app.get('/', function (req, res) {
  res.sendfile(__dirname + '/index.html');
});

app.listen(3000);
複製代碼

二、客戶端

代碼以下,向8080端口發起WebSocket鏈接。鏈接創建後,打印日誌,同時向服務端發送消息。接收到來自服務端的消息後,一樣打印日誌。

<script> var ws = new WebSocket('ws://localhost:8080'); ws.onopen = function () { console.log('ws onopen'); ws.send('from client: hello'); }; ws.onmessage = function (e) { console.log('ws onmessage'); console.log('from server: ' + e.data); }; </script> 複製代碼

三、運行結果

可分別查看服務端、客戶端的日誌,這裏不展開。

服務端輸出:

server: receive connection.
server: received hello
複製代碼

客戶端輸出:

client: ws connection is open
client: received world
複製代碼

4、如何創建鏈接

前面提到,WebSocket複用了HTTP的握手通道。具體指的是,客戶端經過HTTP請求與WebSocket服務端協商升級協議。協議升級完成後,後續的數據交換則遵守WebSocket的協議。

一、客戶端:申請協議升級

首先,客戶端發起協議升級請求。能夠看到,採用的是標準的HTTP報文格式,且只支持GET方法。

GET / HTTP/1.1
Host: localhost:8080
Origin: http://127.0.0.1:3000
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw==
複製代碼

重點請求首部意義以下:

  • Connection: Upgrade:表示要升級協議
  • Upgrade: websocket:表示要升級到websocket協議。
  • Sec-WebSocket-Version: 13:表示websocket的版本。若是服務端不支持該版本,須要返回一個Sec-WebSocket-Versionheader,裏面包含服務端支持的版本號。
  • Sec-WebSocket-Key:與後面服務端響應首部的Sec-WebSocket-Accept是配套的,提供基本的防禦,好比惡意的鏈接,或者無心的鏈接。

注意,上面請求省略了部分非重點請求首部。因爲是標準的HTTP請求,相似Host、Origin、Cookie等請求首部會照常發送。在握手階段,能夠經過相關請求首部進行 安全限制、權限校驗等。

二、服務端:響應協議升級

服務端返回內容以下,狀態代碼101表示協議切換。到此完成協議升級,後續的數據交互都按照新的協議來。

HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

複製代碼

備註:每一個header都以\r\n結尾,而且最後一行加上一個額外的空行\r\n。此外,服務端迴應的HTTP狀態碼只能在握手階段使用。過了握手階段後,就只能採用特定的錯誤碼。

三、Sec-WebSocket-Accept的計算

Sec-WebSocket-Accept根據客戶端請求首部的Sec-WebSocket-Key計算出來。

計算公式爲:

  1. Sec-WebSocket-Key258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接。
  2. 經過SHA1計算出摘要,並轉成base64字符串。

僞代碼以下:

>toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 )  )
複製代碼

驗證下前面的返回結果:

const crypto = require('crypto');
const magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const secWebSocketKey = 'w4v7O6xFTi36lq3RNcgctw==';

let secWebSocketAccept = crypto.createHash('sha1')
	.update(secWebSocketKey + magic)
	.digest('base64');

console.log(secWebSocketAccept);
// Oy4NRAQ13jhfONC7bP8dTKb4PTU=
複製代碼

5、數據幀格式

客戶端、服務端數據的交換,離不開數據幀格式的定義。所以,在實際講解數據交換以前,咱們先來看下WebSocket的數據幀格式。

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

  1. 發送端:將消息切割成多個幀,併發送給服務端;
  2. 接收端:接收消息幀,並將關聯的幀從新組裝成完整的消息;

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

一、數據幀格式概覽

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

  1. 從左到右,單位是比特。好比FINRSV1各佔據1比特,opcode佔據4比特。
  2. 內容包括了標識、操做代碼、掩碼、數據、數據長度等。(下一小節會展開)
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 ...                |
 +---------------------------------------------------------------+
複製代碼

二、數據幀格式詳解

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

FIN:1個比特。

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

RSV1, RSV2, RSV3:各佔1個比特。

通常狀況下全爲0。當客戶端、服務端協商採用WebSocket擴展時,這三個標誌位能夠非0,且值的含義由擴展進行定義。若是出現非零的值,且並無採用WebSocket擴展,鏈接出錯。

Opcode: 4個比特。

操做代碼,Opcode的值決定了應該如何解析後續的數據載荷(data payload)。若是操做代碼是不認識的,那麼接收端應該斷開鏈接(fail the connection)。可選的操做代碼以下:

  • %x0:表示一個延續幀。當Opcode爲0時,表示本次數據傳輸採用了數據分片,當前收到的數據幀爲其中一個數據分片。
  • %x1:表示這是一個文本幀(frame)
  • %x2:表示這是一個二進制幀(frame)
  • %x3-7:保留的操做代碼,用於後續定義的非控制幀。
  • %x8:表示鏈接斷開。
  • %x9:表示這是一個ping操做。
  • %xA:表示這是一個pong操做。
  • %xB-F:保留的操做代碼,用於後續定義的控制幀。

Mask: 1個比特。

表示是否要對數據載荷進行掩碼操做。從客戶端向服務端發送數據時,須要對數據進行掩碼操做;從服務端向客戶端發送數據時,不須要對數據進行掩碼操做。

若是服務端接收到的數據沒有進行過掩碼操做,服務端須要斷開鏈接。

若是Mask是1,那麼在Masking-key中會定義一個掩碼鍵(masking key),並用這個掩碼鍵來對數據載荷進行反掩碼。全部客戶端發送到服務端的數據幀,Mask都是1。

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

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,重要的位在前)。

Masking-key:0或4字節(32位)

全部從客戶端傳送到服務端的數據幀,數據載荷都進行了掩碼操做,Mask爲1,且攜帶了4字節的Masking-key。若是Mask爲0,則沒有Masking-key。

備註:載荷數據的長度,不包括mask key的長度。

Payload data:(x+y) 字節

載荷數據:包括了擴展數據、應用數據。其中,擴展數據x字節,應用數據y字節。

擴展數據:若是沒有協商使用擴展的話,擴展數據數據爲0字節。全部的擴展都必須聲明擴展數據的長度,或者能夠如何計算出擴展數據的長度。此外,擴展如何使用必須在握手階段就協商好。若是擴展數據存在,那麼載荷數據長度必須將擴展數據的長度包含在內。

應用數據:任意的應用數據,在擴展數據以後(若是存在擴展數據),佔據了數據幀剩餘的位置。載荷數據長度 減去 擴展數據長度,就獲得應用數據的長度。

三、掩碼算法

掩碼鍵(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

6、數據傳遞

一旦WebSocket客戶端、服務端創建鏈接後,後續的操做都是基於數據幀的傳遞。

WebSocket根據opcode來區分操做的類型。好比0x8表示斷開鏈接,0x0-0x2表示數據交互。

一、數據分片

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

FIN=1表示當前數據幀爲消息的最後一個數據幀,此時接收方已經收到完整的消息,能夠對消息進行處理。FIN=0,則接收方還須要繼續監聽接收其他的數據幀。

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

二、數據分片例子

直接看例子更形象些。下面例子來自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!
複製代碼

7、鏈接保持+心跳

WebSocket爲了保持客戶端、服務端的實時雙向通訊,須要確保客戶端、服務端之間的TCP通道保持鏈接沒有斷開。然而,對於長時間沒有數據往來的鏈接,若是依舊長時間保持着,可能會浪費包括的鏈接資源。

但不排除有些場景,客戶端、服務端雖然長時間沒有數據往來,但仍須要保持鏈接。這個時候,能夠採用心跳來實現。

  • 發送方->接收方:ping
  • 接收方->發送方:pong

ping、pong的操做,對應的是WebSocket的兩個控制幀,opcode分別是0x90xA

舉例,WebSocket服務端向客戶端發送ping,只須要以下代碼(採用ws模塊)

ws.ping('', false, true);
複製代碼

8、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服務端,其實並無實際性的保證。

9、數據掩碼的做用

WebSocket協議中,數據掩碼的做用是加強協議的安全性。但數據掩碼並非爲了保護數據自己,由於算法自己是公開的,運算也不復雜。除了加密通道自己,彷佛沒有太多有效的保護通訊安全的辦法。

那麼爲何還要引入掩碼計算呢,除了增長計算機器的運算量外彷佛並無太多的收益(這也是很多同窗疑惑的點)。

答案仍是兩個字:安全。但並非爲了防止數據泄密,而是爲了防止早期版本的協議中存在的代理緩存污染攻擊(proxy cache poisoning attacks)等問題。

一、代理緩存污染攻擊

下面摘自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 [5]. 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. 攻擊者瀏覽器 向 邪惡服務器 發起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>
複製代碼

二、當前解決方案

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

須要注意的是,這裏只是限制了瀏覽器對數據載荷進行掩碼處理,可是壞人徹底能夠實現本身的WebSocket客戶端、服務端,不按規則來,攻擊能夠照常進行。

可是對瀏覽器加上這個限制後,能夠大大增長攻擊的難度,以及攻擊的影響範圍。若是沒有這個限制,只須要在網上放個釣魚網站騙人去訪問,一會兒就能夠在短期內展開大範圍的攻擊。

10、寫在後面

WebSocket可寫的東西還挺多,好比WebSocket擴展。客戶端、服務端之間是如何協商、使用擴展的。WebSocket擴展能夠給協議自己增長不少能力和想象空間,好比數據的壓縮、加密,以及多路複用等。

篇幅所限,這裏先不展開,感興趣的同窗能夠留言交流。文章若有錯漏,敬請指出。

11、相關連接

RFC6455:websocket規範

規範:數據幀掩碼細節

規範:數據幀格式

server-example

編寫websocket服務器

對網絡基礎設施的攻擊(數據掩碼操做所要預防的事情)

Talking to Yourself for Fun and Profit(含有攻擊描述)

What is Sec-WebSocket-Key for?

10.3. Attacks On Infrastructure (Masking)

Talking to Yourself for Fun and Profit

Why are WebSockets masked?

How does websocket frame masking protect against cache poisoning?

What is the mask in a WebSocket frame?

相關文章
相關標籤/搜索