WebSocket サーバーでよく見る規則に、「Origin ヘッダーが自分たちのものでなければ upgrade を拒否する」があります。良い規則ですが、ここから正反対の勘違いが2つ生まれます。「これで自分たちのアプリしか接続できない」と考えるものと、コマンドラインのツールやネイティブアプリは Origin を送らないので、サーバーは拒否すべきだと考えるものです。

どちらも、このヘッダーが何のためにあるかの取り違えから来ています。小さなサーバーとブラウザからの攻撃を作り、主張を1つずつ試しました。

TL;DR

  • Origin は、どのページが接続を開いたかについてブラウザが述べる内容です。ページのスクリプトは変更できませんが、ブラウザ以外のクライアントは好きな値を送れます。RFC 6455 も、ブラウザ以外のクライアントは「偽の Origin ヘッダーを送れる」と書いています。
  • 許可リストが防ぐのは1つの攻撃です。悪意あるページが、被害者のブラウザに自分のサーバーへの WebSocket を開かせ、ブラウザが被害者の Cookie を付けてしまうものです。実験では、検証がなければこの攻撃は成功し、完全一致の検証があれば失敗しました。
  • 同じ検証は、ブラウザ以外のクライアントについては何も教えてくれません。許可された Origin を名乗り Cookie を持たない Node クライアントは 401 でした。盗んだ Cookie を持たせると入れました。認証したのは Cookie で、ヘッダーは何もしていません。
  • したがって規則は2段になります。Origin があれば許可リストと完全一致させます。値にかかわらず、接続にはブラウザが勝手に付けない資格情報も必要です。
  • includes や startsWith で比較すると、https://app.example.com.evil.test が通ります。シリアライズされた origin を集合で引いてください。

RFC 6455 は Origin を何のためと言っているか

ブラウザは WebSocket を開くとき、ページの origin(スキーム、ホスト、ポート)を Origin ヘッダーに入れます。ページのスクリプトは上書きできません。MDN は Origin を禁止リクエストヘッダーに挙げています。RFC 6455 の4.1節は、ブラウザのクライアントにはこのヘッダーを必須(MUST)とし、それ以外のクライアントには、用途が合う場合に限って送ってよい(MAY)としています。

目的は10.2節に書かれています。ブラウザ内で動くスクリプトからの利用を制限したいサーバーは Origin を確認し、受け入れられないなら 403 を返すべきだというものです。狙いは「ブラウザ以外が接続するのを防ぐことではなく」、悪意ある JavaScript の支配下にある信頼されたブラウザが、ハンドシェイクを偽造できないようにすることだと明記されています。確認しないサーバーは「どこからの接続も受け入れる」ことになります(4.2.2節)。

契約はこれがすべてです。このヘッダーは、ブラウザがどのウェブサイトのために動いているかをサーバーが知る手段にすぎず、それ以外は何も保証しません。

誤解1:「許可リストがあれば、自分たちのアプリだけが接続できる」

完全一致の許可リストと Cookie セッションだけを確認するサーバーを起動し、許可された origin を名乗る Node クライアントで接続しました。

Node クライアント、許可された Origin を偽装、Cookie なし    => HTTP 401
Node クライアント、許可された Origin を偽装、Cookie あり    => OPEN {"hello":"alice", ...}

Cookie がなければ偽装した Origin は何の役にも立たず、Cookie があれば不要でした。サーバーが認証したのは Cookie です。ヘッダーは必要条件でも十分条件でもありません。このヘッダーを信用できるのは、それを強制するブラウザの場合だけです。

誤解2:「Cookie と SameSite を使っているから大丈夫」

許可リストはこの攻撃のためにあるので、実際に動かしました。構成は次のとおりです。

  1. サーバー(ポート4100)は Cookie セッションを受け付け、Origin を見ません。
  2. headless Chrome(バージョン154、puppeteer-core で操作)で、被害者が http://localhost:4100/login を開き、sid=...; SameSite=Lax を受け取ります。
  3. 同じブラウザで、別のサーバー(ポート4200)のページを開きます。そのページが new WebSocket('ws://localhost:4100/ws') を実行します。
MODE=none       :4200 のページ、被害者の Cookie   => OPEN {"hello":"alice","origin":"http://localhost:4200"}
MODE=allowlist  :4200 のページ、被害者の Cookie   => CLOSED code=1006   (サーバーは Origin=http://localhost:4200 を見て 403)

悪意あるページは被害者のデータを受け取りました。サーバーのログには Origin: http://localhost:4200 が残っており、拒否できたはずです。OWASP の WebSocket チートシートが cross-site WebSocket hijacking と呼ぶ攻撃です。

なぜ SameSite=Lax が効かなかったのでしょうか。localhost のポート4200とポート4100は、cross-origin ですが same-site だからです。site の判定ではポートが無視されます。違いはweb.dev の解説にあります。同じ登録可能ドメインの兄弟サブドメイン同士でも同じことが起きます。僕が試したのは localhost のポートだけです。サブドメインの場合は定義から導かれることで、実行はしていません。SameSite だけを頼りにすると、同じ site にあってスクリプトを動かせる別のページ(ユーザーのコンテンツを置くサブドメインや、ステージングの複製)が、被害者の Cookie で接続を開けます。

誤解3:「origin を比較しているから検証できている」

origin は文字列として比較されるので、比較の書き方が結果を左右します。もっともらしい実装を3つ、悪意ある4つの値に対して試しました。

const ALLOWED = ['https://app.example.com'];
includes:   (o) => ALLOWED.some((a) => o.includes(new URL(a).host)),
startsWith: (o) => ALLOWED.some((a) => o.startsWith(a)),
exactSet:   (o) => new Set(ALLOWED).has(o),

includes は https://app.example.com.evil.test、https://evil.test/?https://app.example.com、https://notapp.example.com を通しました。startsWith は最初の1つを通しました。集合による完全一致は、文字列の null を含む4つすべてを拒否しました。ブラウザが送る origin はパスも末尾のスラッシュもない形でシリアライズされているので、完全一致は正確で、書くのも簡単です。

誤解4:「Origin がなければ拒否」(または「なければ問題ない」)

Origin のないリクエストは、ブラウザの規則に従っていないものから来ています。デスクトップアプリ、スクリプト、サーバー、テストツールなどです。これらをすべて拒否すると、自分たちのブラウザ以外のクライアントも動かなくなります。資格情報なしで受け入れれば、検証には意味がなくなります。正しい読み方は、Origin がないとブラウザの助けがなくなるので、リクエスト自身が証明を持つ必要がある、というものです。

その証明は、ブラウザが自分では送らないものでなければなりません。Cookie はその条件を満たしません。上の攻撃はまさに Cookie に乗っています。Authorization ヘッダーは、ブラウザの WebSocket API からは設定できません。ページのスクリプトが渡せるのはサブプロトコルのリストで、僕のサーバーはそこでベアラートークンを受け取ります(OWASP のシートは、クエリ文字列や最初のメッセージで渡す方法も挙げています。クエリ文字列はアクセスログに残ります)。

判定をコードにしたものが次です。実験用のサーバーが実行している関数そのものです。

export function gate({ origin, bearer, allowed, tokens }) {
  if (origin !== undefined && !allowed.has(origin)) return { ok: false, status: 403, why: 'origin-not-allowed' };
  const user = tokens[bearer];
  if (!user) return { ok: false, status: 401, why: 'no-valid-bearer' };
  return { ok: true, user, kind: origin === undefined ? 'no-origin' : 'browser' };
}

ヘッダーがないとき origin は undefined です。sandbox 付きの iframe は文字列の null を送りますが、これは集合にないので 403 になります。

同じサーバーを gate モードで動かした結果です。

クライアント結果
Node、トークンあり、Origin なし接続できました
Node、トークンあり、許可された Origin を偽装接続できました
Node、トークンあり、許可されていない Origin403
Node、トークンなし401
Node、誤ったトークン、許可された Origin401
ポート4200のページ(許可外)閉じました。サーバーは origin を見て 403 を返しました
sandbox 付き iframe閉じました。サーバーは Origin: null を見て 403 を返しました

この表で見直す価値があるのは2行です。「トークンあり、許可された Origin を偽装: 接続できました」は問題ありません。有効なトークンを持つクライアントは接続してよく、ヘッダーを偽装しても得るものはありません。「トークンあり、許可されていない Origin: 403」は、規則のうちブラウザ向けの部分です。信用できないページが、何らかの形でトークンを手に入れたブラウザ上で動いていても、拒否されます。

ヘッダーに任せることと、任せないこと

ブラウザから届きうる WebSocket のエンドポイントには、完全一致の Origin 許可リストを置きます。特にセッションが Cookie の場合です。安く済み、サイト間の攻撃を塞げます。

これを唯一の認証にしないでください。ネイティブクライアントとスクリプトを見分ける手段にもなりません。サーバーは、正直に名乗るクライアントと名乗らないクライアントを区別できないからです。特定のアプリのビルドからの接続であることを確かめたいなら、別の問題です(アプリの構成証明で、ここでは調べていません)。その答えもヘッダーではなく資格情報です。

ブラウザ以外の、明示的なトークンを持つクライアントしか届かないエンドポイントでは、この検査があっても害はなく、何も変わりません。そこに Origin ヘッダー付きのリクエストが来たなら、それ自体が記録しておく価値のある兆候です。

Origin 検証がそれでも漏れる場所

間違い起きること対処
部分一致や前方一致での比較紛らわしいドメインが接続し、データを受け取ります。シリアライズされた origin 全体を集合と比べ、上の4つのような悪意ある値をテストに入れます。
*.example.com のようなワイルドカード忘れていたサブドメインや、乗っ取られうるサブドメインが、Cookie 認証付きの接続を開けます。origin を1つずつ列挙します。パターンが必要なら、ユーザーのコンテンツを配信できるサブドメインはすべて悪意ある origin として扱います。
null を許可するsandbox 付き iframe や、data: URL から作った文書が接続できます。MDN は、この2つを Origin が null になる場合に挙げています。null をリストに入れません。
ベアラートークンをクエリ文字列に入れるアクセスログやプロキシのログにトークンが残ります。OWASP のシートがまさにこの点を警告しています。サブプロトコルか最初のメッセージで渡し、ログに残るものは伏せます。
開発用の origin を本番のリストに残すhttp://localhost:3000 が、どのユーザーのマシンからでも受け入れられます。環境ごとの設定からリストを作り、拒否した origin をすべて記録します。
upgrade のあとで検査する最初のメッセージで閉じても、サーバーはすでにデータを送っています。handleUpgrade の前に、HTTP の応答で拒否します。

攻撃を再現する

mkdir origin-try && cd origin-try && npm init -y >/dev/null && npm i [email protected]
# 実験の server.mjs を置く(上の gate 関数に、http サーバーと ws.handleUpgrade を足したもの)
MODE=gate PORT=4100 node server.mjs &
mkdir other && echo '<script>
const ws = new WebSocket("ws://localhost:4100/ws");
ws.onopen = () => document.title = "OPEN"; ws.onclose = (e) => document.title = "CLOSED " + e.code;
</script>' > other/index.html
python3 -m http.server 4200 -d other &
# ブラウザで http://localhost:4200/ を開く。サーバーのログに、見た Origin と判定が出る

MODE=none にして、http://localhost:4100/login で Cookie を受け取ったあとなら、同じページが接続できます。MODE=gate では拒否されます。Node 側は、ws パッケージで new WebSocket(url, ['bearer.tok-bob-native'], { headers: { Origin: 'http://localhost:4100' } }) とすれば、偽装したヘッダーを確かめられます。

実験で確かめたこと、確かめていないこと

この環境で確認したこと(Node.js 22.23.3、ws 8.22.0、headless Chrome 154)です。

  • 別ポートのページからの Cookie セッションの乗っ取りと、完全一致の許可リストによる拒否。
  • 許可された Origin を偽装した Node クライアント。Cookie なしで 401、Cookie ありで接続成功。
  • 上の表の7つのケースにわたる gate 関数の動作。Origin: null を送った sandbox 付き iframe を含みます。
  • 3つの文字列比較を、悪意ある4つの値に対して試した結果(node --test、すべて成功)。
  • 上で引用した RFC 6455 と MDN の記述。RFC の本文と MDN のページで確認しました。

確認していないことです。

  • ほかのブラウザ。Chrome だけで試しました。
  • サブドメイン間の乗っ取り。また、使っているプロキシや CDN が Origin を転送するか書き換えるか。受け取ったヘッダーを記録するリクエストで確かめてください。
  • 本物の証明書を使った wss://。実験では localhost の ws:// を使いました。
  • ネイティブアプリの構成証明に関する主張。

ws のサーバーは実験用に書いた最小のもので、本番の構成ではありません。中の秘密(sid-alice、tok-bob-native)は作り物です。

僕ならこう出荷します

Origin は、どのウェブサイトがブラウザを通して話しているかを、ブラウザがサーバーに教えるものです。悪意あるウェブサイトへの対策としては適切で、ブラウザ以外の相手については何も言いません。完全一致で確認し、一致しなければ拒否し、すべての接続に、ブラウザが勝手には付けない明示的な資格情報を求めてください。