🧬

生き延びるためのWebSocket認証

に公開

はじめに

WebSocket、使ってますか?
WebSocketには、ウェブ開発者がもっとも慣れ親しんでいるであろうHTTPによる通信とは異なった考慮を要するところがいくつかあります。認証はそのひとつです。

たとえば、ブラウザのWebSocket APIではhandshake時にカスタムヘッダーを付与することができません[1]。そのため、たとえばリクエストのAuthorizationヘッダーにトークンを設定してサーバ側で認証する定番の方法は使えません。

WebSocketプロトコルを定めているRFC6455においても、認証についての記述は以下のような漠然としたものにとどまっています(日本語訳は筆者)。

This protocol doesn't prescribe any particular way that servers can
authenticate clients during the WebSocket handshake. The WebSocket
server can use any client authentication mechanism available to a
generic HTTP server, such as cookies, HTTP authentication, or TLS
authentication.

このプロトコルは、WebSocketハンドシェイク中にサーバーがクライアントを認証する特定の方法を定めていません。WebSocketサーバーは、Cookie、HTTP認証、TLS認証などの、一般的なHTTPサーバーで利用可能な任意のクライアント認証メカニズムを使用できます。

では、どのような認証方法が実用的なのでしょうか。以下で概観していきましょう。

前提

  • クライアントはブラウザのWebSocket APIでWebSocketエンドポイントに接続する
  • 誰でも接続できるのは困るので、接続時に何らかの認証を設けたい

というシチュエーションを前提とします[2][3]

ライブラリやランタイムによる認証のサポートについては後に補足します。

認証手法

1. 「チケット」ベースの認証

以下ではまず、WebSocket のセキュリティ(devcenter.heroku.com)で紹介されている「チケット」ベースの認証を主に取り扱います。
これは、以下のようにして認証を実施する手法です。認証用の「チケット」を作成できる(HTTP)サーバがあることが前提となります[4]

  1. クライアントはWebSocket接続を開始するにあたって、まず「チケット」を得るためにHTTPサーバに接続する
  2. HTTPサーバはチケットを作成する[5]
  3. サーバーはこのチケットを (データベースあるいはキャッシュ内に) 保存して、クライアントにも返する
  4. クライアントはWebSocketサーバに接続し、この「チケット」を初期ハンドシェイクの一環として送る
  5. WebSocketサーバはこのチケットを検証する

この認証手法は扱いやすく、HTTPサーバとWebSocketサーバが分離されているというよくある状況もカバーしています。

クエリパラメータに入れる

const token = "your_auth_token";

const uri = `wss://example.com/api/websocket?token=${token}`;
const webSocket = new WebSocket(uri);

クエリパラメータにトークンを格納し、サーバ側で認証する方法です。

クエリパラメータはURLの一部であるため、トークンがどこかでログに出力されるおそれがあります[6]
そのため、この方法を取る場合は、トークンの有効期限を極度に短くしたり、一度しか利用できないようにするなどの工夫が必要になります。

"First Message"方式

const token = "your_auth_token";

const uri = "wss://example.com/api/websocket";
const websocket = new WebSocket(uri);
websocket.onopen = () => websocket.send(token);

"First Message"などと呼ばれている手法です。
まず、WebSocketサーバは認証なしでWebSocket接続を確立します。
その後、クライアントは接続open後に最初のメッセージとしてトークンをサーバに送信します。認証に失敗した場合は、サーバは接続を切断します。

これはクエリパラメータを利用する方式とくらべて認証情報が漏れるリスクが低く、実装も容易であるため、有力な手法のひとつです。
ただし、認証なしでWebSocket接続が確立されてしまうため、それがアプリケーションの要件にマッチするのかは検討する必要があります[7]

Sec-WebSocket-Protocolヘッダーを利用

const token = "your_auth_token";

const uri = `wss://example.com/api/websocket`;
// new WebSocket(uri, protocols)
const webSocket = new WebSocket(uri, ["your-protocol" ,token]);

[8]

Sec-WebSocket-Protocolというヘッダーを利用する手法です。
このヘッダーは、WebSocketのクライアントとサーバがサブプロトコルについて合意するためのヘッダーです。サブプロトコルとは、クライアントとサーバの間で合意される制約です。
たとえば、Sec-WebSocket-Protocol: json[9]を指定することで、データがJSON形式でやりとりされることを合意できます。

このヘッダーはWebSocketのhandshake時にWebSocket APIから自由に値を設定できる唯一のヘッダーです。
また、このサブプロトコル名はWebSocket Subprotocol Name Registryから選ぶほかに、クライアントとサーバーが合意する任意のカスタム名を設定することもできます[10]

この二点の理由から、Sec-WebSocket-Protocolヘッダーにトークンを入れ、サーバ側で認証するというハックが成立する余地があります。
乱暴な方法にみえますが、意外とメジャーなソフトウェアでも使われています[11]
たとえば、Kubernetes APIのWebSocket認証に使われている例が確認できます。
https://github.com/kubernetes/kubernetes/commit/714f97d7baf4975ad3aa47735a868a81a984d1f0

また、AWS AppSyncでもこのヘッダーが利用されています。

WebSocketプロトコルの意図にそぐわないため、やや抵抗感がある手法ですが、利用できる場面もありそうです。

2. その他の認証

WebSocketの接続確立時に認証情報を格納したCookieを送信し、サーバで検証する方法です。
この方法は機能しますが、採用時にいくつか考慮すべき点があります。

まず、WebSocket接続はCORSポリシーの対象外であるため、何も対策をしなかった場合はクロスサイトWebSocketハイジャック(CSWSH)攻撃が成立します。

https://christian-schneider.net/blog/cross-site-websocket-hijacking/

この攻撃手法はCSRF(クロスサイトリクエストフォージェリ)と同じ原理によるもので、WebSocketにはCORS制御がないことから、good.example.comのWebSocketエンドポイントに対してevil.example.comから容易に接続をひらいてCookieを送信させることができます。

これにはいくつかの対策が考えられ、主要なものとしては

  1. Originヘッダーをサーバ側で検証する
  2. CSRFトークンに類する仕組みを利用

などがあります。

また、これはWebSocketと関係ない一般的な注意ですが、クライアントとサーバが異なるドメインで動作している場合はCookieを送信することができなくなります。WebSocketサーバはHTTPサーバと別で運用することもあるかと思いますので、そういった場合は注意が必要です。

Basic認証

wss://username:password@example.com/api/websocket/のようにBasic認証を利用する方法です。
Basic認証はRFCにおいて利用可能な方法として挙げられていますが、モダンなブラウザでは上記のようなURLが利用できなくなりつつあるため、実質的には利用できません[12]

https://developer.mozilla.org/ja/docs/Web/HTTP/Guides/Authentication#url_内の認証情報を使用したアクセス

TLS

RFC6455でいうTLS認証とは、いわゆる「相互TLS認証」を意味しています。
一般的なWebサービスの場合、ユーザに証明書を設定させるのは現実的ではないと思います。

補足: 認証をサポートしてくれるライブラリやランタイム

いくつかのライブラリやランタイムは、WebSocket経由の認証をサポートしています。

Socket.IO

https://socket.io/docs/v4/middlewares/#sending-credentials

Socket.IOでは以下のようなコードで認証トークンを利用することができます。

クライアント側

// plain object
const socket = io({
  auth: {
    token: "abc"
  }
});

// or with a function
const socket = io({
  auth: (cb) => {
    cb({
      token: "abc"
    });
  }
});

サーバ側

io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  // ...
});

これは内部的には、"First Message"方式を利用しているようです。
https://zenn.dev/chot/articles/socket-io-auth-token

Deno

https://docs.deno.com/api/web/~/WebSocket

// WebSocket with headers
const wsWithProtocols = new WebSocket("ws://localhost:8080", {
  headers: {
    "Authorization": "Bearer foo",
  },
});

DenoのWebsocket APIにはヘッダーを利用できる独自拡張が存在します。

結論

自身で実装する場合は"First Message"方式を利用するのがもっとも悩みが少ないのではないでしょうか。
ライブラリが利用可能な場合は、それに頼るのもよいでしょう。
それではみなさま、よきWebSocketライフをお送りください。

参考リンク

https://nykergoto.hatenablog.jp/entry/2021/05/12/Websocket_の認証_(Authentication)_に関するメモ

"First Message"方式について日本語で説明されています。

https://stackoverflow.com/a/77060459

認証の選択肢がよくまとまっているstackoverflowの回答です。

Nothing has changed in the 15(!) years since this question was opened.

という一節が印象的でした。

https://lucumr.pocoo.org/2012/9/24/websockets-101/

Armin Ronacher(Flaskの作成者)が2012年に書いた記事。WebSocketにともなう複雑さや困難さがよくわかる記事で、まだ古びていません。

Websockets make you sad. There, I said it. What started out as a really small simple thing ended up as an abomination of (what feels like) needles complexity.

WebSocketはあなたを不幸にする。ああ、言ってしまった。ほんとうに小さくてシンプルなものとしてはじまったのに、結局は不必要(としか思えない)複雑さをもつ忌まわしい代物になってしまった。[13]

脚注
  1. カスタムヘッダーに関する議論は、whatwg/websocketsのIssue Support for custom headers for handshake #16で読むことができます。 ↩︎

  2. 本記事では、「これが認証に使えそう」という手法を列挙することを目的とし、認証の詳細には踏み込みません。また、ブラウザ以外から接続する場合でも、本記事の内容が役に立つことはあるかもしれませんが、議論の前提が異なる可能性があります。 ↩︎

  3. 接続中の各メッセージに対する認証や、認証情報の失効時の対応については、本記事では取り扱いません。 ↩︎

  4. このHTTPサーバにはなんらかの認証があることが前提になっています。これは一見すると問題の箇所をずらしただけに見えるかもしれませんが、実際のところ、WebSocketサーバ単体でサービスを構成することはないと思います。HTTPによって稼働するサービスと組み合わせるのが基本的なパターンであり、そのため、この前提はそれほど奇妙なものではないと思います。 ↩︎

  5. チケットにはユーザIDなど、記録/管理に必要な情報を含めるべきです。 ↩︎

  6. 「クエリパラメータは暗号化されないためプロキシサーバなどで読み取られるおそれがある」としている記事が多いですが、HTTPSを利用している場合はIPアドレスとホスト名以外の要素はすべて暗号化されます。つまり、URLのパス部分も暗号化されます(参考: Introduction to HTTPS)。また、ブラウザの履歴やブックマークとして記録されたり、誰かに肩越しに覗かれたりする可能性があるのもクエリパラメータの欠点のひとつですが、これらはWebSocketのエンドポイントならそれほど気にしなくてもよいのではないかと思います。もちろん、本文中に記載したように、短命なトークンを利用するなどの配慮は必要となります。 ↩︎

  7. たとえば、接続を開くだけ開いて認証トークンを送信しないことによって認証なしでWebSocket接続数を消費するような攻撃が考えられます。これについては、「First Message」送信までのタイムアウトを設定し、一定時間が経ったのちに接続を切断することが対策になりそうです。 ↩︎

  8. このサンプルコードで、トークン以外にyour-protocolというプロトコルを引数に含めている理由はSec-WebSocket-Protocolの仕様にあります。サーバはクライアントが提示したサブプロトコルのリストの中から、自身がサポートする最初のものを選んで応答することになっています。トークンをレスポンスに含めるのは不都合なため、この認証方法をもちいる場合、サーバは「トークンでない」プロトコルで応答するべきです。 ↩︎

  9. ドキュメントには、名前衝突を避けるために"json.example.com"のようにドメイン名を含む名前を使用することが推奨されています。 ↩︎

  10. Directives ↩︎

  11. メジャーなソフトウェアで採用されているからOKだ、という意図ではありません。この手法がどの程度よくないのかは筆者も正直よくわからないところで、 ↩︎

  12. また、HTTPSを利用しなければ危険です。まあ、阿部寛のホームページさえHTTPS化したいまの時代では当然という感じもします。 ↩︎

  13. 訳は筆者。 ↩︎

Discussion