理系学生日記

おまえはいつまで学生気分なのか

忍者TOOLS

WebSocketプロトコルの中身

WebSocket についてはぜんぜん調べたことなくて、HTTP の上に構築された双方向通信プロトコルやろ、とか思ってました。 Golang で WebSocket サーバを作ったのがきっかけで、きちんと WebSocket の仕様を調べてみたところ、その理解が根底から覆されました。

RFC 6455 - The WebSocket Protocol とキャプチャ結果をもとにして、ちょっと WebSocket を追ってみます。

WebSocket 概観

WebSocket は、TCP の上でブラウザベースの双方向通信を実現しようというプロトコルです。

テキストとバイナリ転送の双方に対応しており、これらの情報は「フレーム」という単位でやりとりされます。

このフレームにはデータフレームと制御フレームがあります。データフレームがテキストやバイナリデータを転送するフレームで、制御フレームが Close やら Ping/Pong といったことをエンドポイント(クライアント/サーバ) に指示するためのフレームです。

フレームってことはその長さには限界もありますし、データがクソ長いときのために、フレーム自体はフラグメンテーションをサポートしています。つまり、クソ長いデータは複数フレームに分割されることになります。

プロトコルデータは以下のようになります。(RFC 6455 - The WebSocket Protocol より引用)

    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 ...                |
     +---------------------------------------------------------------+

で、ここまでの話に HTTP は登場していません。

ではどこに HTTP が関わっているかというと、通信開始時のブラウザ・サーバ間のハンドシェークです。 最初から TCP の上で HTTP とは完全に別のプロトコルを設計しようと思えばできたでしょうが、HTTP の世界がここまで広がってきている中で、HTTP に対応した NW 機器、サーバには相応の投資がされています。 これらを活用した上で、なんとかして双方向通信を実現しようとしようというのが WebSocket になります。

ハンドシェーク

gorilla/websocket を使って WebSocket サーバを構築し、実際の通信をキャプチャしてみました。

大まかな流れで言えば、ハンドシェークはブラウザ(クライアント)から開始され、

  1. いくつかのヘッダを伴う HTTP/1.1 GET でクライアントからサーバにハンドシェークを開始
  2. サーバから、やはりいくつかのヘッダを伴う ステータス 101 Switching Protocols を返却

を経て、WebSocket コネクションが確立し「OPEN」状態になります。この「OPEN」な状態になれば、互いにデータを送受信できるようになります。

開始

まず、ハンドシェークは、ブラウザ(クライアント)から開始されます。 以下が実際のハンドシェークの開始です。

f:id:kiririmode:20180502135429p:plain

ハンドシェークは、HTTP/1.1 の GET リクエストに、複数の WebSocket 用ヘッダを組み合わせたものになります。 ブラウザからの WebSocket 通信を前提にした場合、必須ヘッダは以下になります。value まで書いたものは固定値です。

  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Version: 13
  • Host
  • Sec-WebSocket-Key
  • Origin

HostOrigin は HTTP/1.1 だとお馴染ですが、Sec-WebSocket-Key はコネクションごとに払出すランダムな 16 byte 値(を base64 でエンコードしたもの)です。 この Sec-WebSocket-Key をサーバが読み取り、サーバはクライアントに「コネクション確立を受け付ける」ことを通知するため、ちょっとした加工を経てクライアントに Sec-WebSocket-Accept ヘッダとして送り返します。

Origin ヘッダについては、サーバ側での妥当性検証に使われることがありまして、例えば gorilla/websocket ではデフォルトでは CheckSameOrigin が適用されます。

// checkSameOrigin returns true if the origin is not set or is equal to the request host.
func checkSameOrigin(r *http.Request) bool {
    origin := r.Header["Origin"]
    if len(origin) == 0 {
        return true
    }
    u, err := url.Parse(origin[0])
    if err != nil {
        return false
    }
    return equalASCIIFold(u.Host, r.Host)
}

この他、キャプチャ結果においては Sec-WebSocket-Extensions ヘッダで色々指定がされていますが、このあたりは RFC 7692 - Compression Extensions for WebSocket を参照してください。

確立

サーバからの応答はすごくシンプルです。

f:id:kiririmode:20180502143217p:plain

先に記述したように、レスポンスは HTTP/1.1 の 101 Switching Protocols、必須ヘッダは

  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Accept

です。 これを返した時点で、WebSocket コネクションは「OPEN」になり、双方向通信が開始されます。

データ通信

Golang で単純な WebSocket での Echo サーバを作って試してみました。 以下がそれぞれの方向でのパケットのキャプチャ結果ですが、TCP の次のレイヤが WebSocket になっており、もはや HTTP は介在していないことが分かります。

f:id:kiririmode:20180502144106p:plain
ブラウザからサーバに`Hello, World`送信

f:id:kiririmode:20180502144119p:plain
サーバからブラウザに`Hello, World` をEcho

両方向ともにいくつかビットが立っていますが、Fin が示しているのは最後のフラグメントであることを示しています。この場合は Hello, World という短い文字列しか送信してないので、最初であり最後のフラグメントってことですね。

おもしろいのは Mask のビットで、クライアント→サーバの場合はマスク、逆方向であるサーバ→クライアントはマスクしないという結果になっています。 (これは RFC 6455 - The WebSocket Protocol に従った挙動です)

ここでの「マスク」っていうのは、単なる XOR 演算で、別にセキュリティを意図したものでもなさそうです。このあたりはもうすこし追っかけたいと思います。

マスキングについて追記

マスキングについては、

にすごく分かりやすいエントリがあったので、そちらを見れば良いと思います。 結論としては、キャッシュポイズニング対策で、セキュリティを意図したものでした。

RFC の 10.3 Attacks On Infrastructure (Masking) にもちゃんと書いてあった。