安全のしくみ
部屋のサーバー(Cloudflare版とVercel版)と、部屋に置く画像と音声について、何を守り、何を守らないのかを説明します。置くかどうかを決めるときや、置いたあとの運用の参考にしてください。SkyWay版のトークン発行サーバーの守りは、SkyWay版のしくみにあります。
守っていること
画像と音声は、その部屋の席だけが読み書きできる
画像と音声は、部屋のアセットとして部屋ごとに分けて置きます(置き場所での名前はrooms/<部屋の鍵>/<SHA-256>)。読むのも置くのも、その部屋の席にいる人だけです。
- どのリクエストにも、部屋のサーバーが部屋に入った席に渡すトークンが要ります。トークンには部屋の鍵、席の役割とユーザーIDが入っていて、
SESSION_SECRETで署名し、15分で切れます(→ 部屋のアセットのトークン)。 - 読むのはその部屋の席ならだれでも(GM・PL・見学)、置くのはGMとPLだけです。
- 置くときは、トークンの期限が残っていても、部屋がまだあり、その人がいまGMかPLとして席にいるかを確かめます。削除した部屋には置けず、見学に変えられた人や抜けた人も置けません。
そのため、名前(SHA-256)を知っているだけでは読めません。部屋の鍵は招待リンクに載り、部屋の一覧もだれにでも部屋の鍵を返します。鍵を知っていてもトークンが無ければ読めませんが、パスワードの無い部屋なら、だれでも見学で入ってトークンを受け取れます(→ 部屋の鍵を知っていれば入れる)。部屋を抜けた人が読めるのは、持っているトークンが切れるまで(最長15分)です。
Cloudflare版がデータセンターのキャッシュ(Cache API)から返すときも、先にトークンを確かめます。答えにはCache-Control: privateを付け、ブラウザには置かせても、共有のキャッシュには置かせません。
名前と中身の照合
画像と音声は、中身のSHA-256を名前にして置きます。PUTの本文のハッシュが名前と合わなければ、何も置きません。照合は、置きながら行います(Cloudflare版はR2が書き込みながら、Vercel版は部屋のサーバーが中身を読みながらハッシュを計算します)。途中で中身をすり替えることはできません。
受け取る側のAxeも、届いた中身のハッシュが名前と合うかを確かめます。合わなければ捨てて、持っている人から部屋のサーバーの中継で受け取ります。
形式の判定
置けるのは、受け付ける画像と音声だけです。宣言されたContent-Typeは信じず、中身の先頭のバイトを見て形式を決め、配るときのContent-Typeもそれにします。これは、ブラウザがスクリプトとして扱えるもの(HTMLなど)を配らないためです。
- SVGは受け付けますが、サンドボックスに入れて配ります。 下の節で説明します。
- MP4やMOVの動画は受け付けません。 M4Aと同じ入れ物ですが、音声として置ける大きさ(10MiB)に収まる動画を、音声として置かせないためです。
- 判定するのは先頭の印だけで、続く中身が壊れていないことまでは確かめません。
配るときはX-Content-Type-Options: nosniffを付け、ブラウザが中身から形式を推測し直さないようにしています。
SVGはサンドボックスに入れて配る
SVGは画像ですが、中に<script>を書けます。Axeは<img>で読むので、そのときはスクリプトは動きません。部屋のアセットは読むのにもトークンが要るので、URLをブラウザで開いても401が返るだけです。それでも、トークンを付けて文書として開かれたときに備え、二重の守りとして次の手を取ります。
そこでSVGを配るときは、次のContent-Security-Policyを付けます。
Content-Security-Policy: default-src 'none'; img-src data:; style-src 'unsafe-inline'; sandboxsandboxで、スクリプトを止め、文書を一時的な別のOriginとして扱わせます。default-src 'none'で、外のものを読みに行かせません。描くのに要る、埋め込みの画像(data:)とstyle属性だけを通します。
あわせて、部屋のサーバーを置くドメインではcookieを使わないでください。Axeやログインの画面とは別のホスト名(例:backend.example.com)にし、親のドメインに向けたcookie(Domain=example.com)も置かないようにします。万一スクリプトが動いても、盗めるものが無いようにするためです。
大きさの上限
画像は2MiB、音声は10MiBまでです。Content-Lengthがこれを超えるPUTは、本文を読む前に断ります。
部屋と一緒に消す
部屋のアセットは、部屋と一緒に消します(→ 部屋が消えるとき)。Cloudflare版では、データセンターのキャッシュの写しが部屋を消したあとも最長7日残りますが、部屋が消えれば新しいトークンは出ないので、写しを読めるのは消してから最長15分です(→ キャッシュ)。
ログイン
- PKCE:ログインから戻ったときのコードは、ログインを始めたAxeが手元に置いた
code_verifierが無いと、トークンに替えられません。コードを横取りされても使えません。 - 戻り先:ログインのあとに戻るのは、
ACCESS_CONTROL_ALLOW_ORIGINに名前で書いたOriginのページだけです。*は数えません。 - Discordからは最小限:求めるのは
identifyだけです。Discordのアクセストークンは、その人の情報を1度読んだら捨てます。 - トークン:アクセストークンは15分で切れます。リフレッシュトークンは使うたびに替わり、使ったものがまた来たら、その系統をすべて無効にします。同じものが同時に2回来ても通るのは1回だけで、2回目で系統ごと無効にします。どれも、そのものではなくハッシュを置きます。
- 消したアカウント:
GETとDELETE /v1/accountは、トークンのユーザーIDがいまのアカウントと同じときだけ答えます(違えば404)。消したあと同じDiscordで作り直したアカウントを、前のアカウントの残ったトークンで見たり消したりはできません。 - ログインを外した部屋のサーバー:あとからログインの3つを外すと、残っている部屋もトークンを一切受けず、見学としてだけ入れます。招待も部屋のアセットのトークンも作りません。
- cookieを使わない:トークンはAxeが手元に置き、
AuthorizationヘッダーかWebSocketの最初のメッセージで送ります。
役割
- 役割(GM・PL・見学)はサーバーが決めて覚えます。
PeerCursorの役割を書き換えても、サーバーが戻します。 PeerCursorは、その席だけが書けて消せます。ピアID・ユーザーID・役割は、サーバーが席の値にして置きます。- ログインしていない人は見学です。見学者が書けるのは、自分のカーソルと、見学者も話せるタブへの発言だけです(→ 役割)。
- 役割を変えられるのはGMだけで、部屋のGMがいなくなる変更は断ります。
- 招待はサーバーが署名し、ほかの部屋では使えません。
- 見学者は、ログインした人のユーザーID(
acct_で始まるもの)と、この部屋にログインして入ったことのあるユーザーIDを名乗れません。見え方はユーザーIDで決まるので、カーソルに見えるユーザーIDを名乗れば、その人の手札や秘話が見えてしまうためです。
見えない情報
裏向きのカードの表、他人宛ての秘話、シークレットダイスの結果などは、見てはいけない人の接続にはそもそも送りません。Axeを改造したり、ブラウザの開発者ツールで通信をのぞいたりしても、届いていないものは見えません。見えないものに書き込もうとしても断り、伏せた形で持っているものを書き戻しても、本物は上書きされません。GMでない人は、データ要素の名前を変えたり、ものを別の親の下に移したりして、自分に見えるものを増やすこともできません。親がまだ届いていないものは、見え方を決められないので、親が届くまでだれにも配りません。
部屋のサーバー
- Origin:部屋を作る・一覧・入るのどれも、
ACCESS_CONTROL_ALLOW_ORIGINに書いたOriginのページからしか受けません。ブラウザはWebSocketにつなぐときもOriginを付けるので、ほかのサイトに置かれたAxeからは使えません。 - パスワード:部屋のパスワードは、そのまま置かずにPBKDF2(SHA-256、10万回)のハッシュにして、卓の状態と一緒に置きます。照らすときは、どこで違っても同じ時間で比べます。間違えると接続を切ります。
- なりすまし:イベントの送り主(
sendFrom)は、サーバーが受けた接続のピアIDで上書きします。サーバー自身のピアID(@authority)を名乗ることもできません。サーバーだけが送るイベント(AUTHORITATIVE_OBJECTなど)は、クライアントが送ってきてもだれにも届けません。ログインした人のピアIDを見学が先に取っていたら、その人が入ってきたときに見学のほうを外します。 - URLに秘密を載せない:パスワードはURLではなく、つないだあとの最初のメッセージで送ります。サーバーのログやブラウザの履歴に残りません。
- 量の制限:部屋を作る・削除する・入るのは送信元ごとに1分30回まで、1つの接続から送れるのは10秒に2000フレームまで、1つの部屋に入れるのは32人までです。gzipを展開して32MiBを超えるものは読まずに切ります。
- 消える:卓の状態と、部屋の画像と音声は、部屋と一緒に消えます(→ 部屋が消えるとき)。
運営する人の管理画面
ADMIN_DISCORD_IDSを入れると、部屋のサーバーの/adminに管理画面ができます(→ 管理画面とBAN)。送信元のIPアドレス(範囲)やDiscordのアカウントをBANし、部屋に入っている接続も切れます。
- 入れるのは、
ADMIN_DISCORD_IDSに書いたDiscordのアカウント(2段階認証が有効なもの)でログインし、さらに端末のパスキーで確かめた人だけです。共有の鍵はありません。 - パスキーは、その部屋のサーバーのページでしか使えないので、にせのページに誘われても盗まれません。
- ログインはHttpOnlyのCookieに置き、30分操作が無いか、12時間で切れます。部屋の削除などの戻せない操作の前には、パスキーでもう一度確かめます。「すべての人をログアウトさせる」で、すべてのログインをその場で切れます。
- 管理画面のAPIは、管理画面のページからの呼び出し(専用のヘッダーが付き、同じOriginのもの)にだけ答えます。ログインとパスキーの試みは、送信元ごとにログインの回数に数えます。
- ログインと、断られた試みは記録に残り、知らせを入れていればDiscordに届きます。
- 管理画面のページは、外のスクリプトやスタイルを読まず、その答えに付けたnonceの付いたものだけを動かします(
Content-Security-Policy)。ほかのページの枠の中には表示させません。 - 部屋の名前やDiscordの表示名は、だれでも決められるので、文字としてだけ表示します。
守っていないこと
置ける人を身内に限るものではない
部屋のアセットは、ACCESS_CONTROL_ALLOW_ORIGINに書いたOriginのページからだけ受けます。ただし、Originヘッダーはブラウザ以外(curlなど)からなら好きな値を付けられます。これは、ほかのサイトに置かれたAxeからあなたの部屋のサーバーが使われるのを防ぐためのもので、置ける人を限るものではありません。
置けるのは、その部屋のGMとPLです。ただし、Discordのアカウントは誰でも作れ、ログインすれば誰でも部屋を作ってGMになれます。ログインは「誰が置いたかわかる」ためのもので、置ける人を身内に限るものではありません。新しく置くPUTは、送信元ごとに1分100件まで(IPv6は/64ごと)に抑えています。
部屋の鍵を知っていれば入れる
部屋の一覧は、許可したOriginのページなら誰でも見られ、そこには部屋の鍵も載っています。パスワードの無い部屋には、一覧から誰でも入れます。SkyWay版のロビーと同じです。ログインしていれば、ほかの人はPLとして入れます。
- メンバーだけで遊ぶときは、パスワードを付けてください。
- 裏向きのカードの表や他人宛ての秘話は、見てはいけない人には届けません(→ 見えない情報)。ただし、わざと隠さないものは全員に届きます。GMにだけ見せるエフェクトの中身や、隠した罠の場所と中身は、改造したAxeなら読めます。
- 部屋に入れた人は、見学でも、その部屋の画像と音声を読めます。
- 見学者の
userIdは、確かめるすべがありません。見学者宛ての秘話、その見学者が送った秘話(あとから秘話にした発言を含む)、その見学者が振ったシークレットダイスの結果は、その見学者のuserIdを名乗った別の見学者にも届きます。名乗った見学者は、その見学者の発言を書き直したり、シークレットダイスの結果を公開したりもでき、同じピアIDまで名乗れば、その見学者を待機に戻せます。Axeは、ログインしていない人を秘話の相手・見せる相手・オーナーに選ばせません。見学者に秘密を伝えるときは、ログインしてもらってください。 - PLは、山札から引いたカードの表を見たあと、持ち主を外して山札の同じ場所へ戻せます。ほかの人の手札から引いたカードも、表を見たあとその人の手札へ渡して戻せます。引くことと渡すことを認めている以上、これは止められません。引いて戻すまでを一度に送れば、ほかの人の画面にはほとんど跡が残りません。
- 山札のシャッフルは、切った人のブラウザで並びを決めます。カードの識別子はシャッフルしても変わらないので、改造したAxeなら、一度表を見たカードをシャッフルのあとも追えます。切る人は、並びを選ぶこともできます。
- SkyWay版の卓を読み込んだ部屋では、手札、秘話、シークレットダイスの結果が、SkyWay版のときのユーザーIDのまま残ります(持ち主と、公開範囲で選んだ人は、読み込むときにAxeが外します)。このIDはログインした人のものではないので、見学者が名乗れば見えてしまいます。読み込んだら、GMが手札を今の参加者に引き継がせてください。前の卓で秘話やシークレットダイスを使っていたなら、残したい発言の無いタブはタブごと削除します。前の参加者どうしの秘話はGMにも届かないので、どのタブにあるかは分かりません。チャットのログの消去では消えません。残したタブでは、見学者が発言できないようにしておきます。前の参加者を名乗った見学者は、その人の昔の発言を書き換えたり、秘話やシークレットダイスの結果を公開したりできるためです。
卓の状態はサーバーに残る
部屋のサーバーは、卓の状態(コマ、カード、チャットなど)を、Cloudflare版ではDurable Objectに、Vercel版ではRedisに置きます。部屋が消えるまでは残ります(→ 部屋が消えるとき)。部屋の画像と音声も、部屋が消えるまで置き場所に残ります。部屋のサーバーを置いた人(運営する人)は、その間、中身を読み出せる立場にあります。
サーバーを呼ぶ回数そのもの
部屋のサーバーの中のレート制限は、部屋のサーバーが呼ばれたあとに働きます。呼ばれた回数そのものは、部屋のサーバーの中では減らせません。
- Cloudflare版で読み出しを繰り返されるのが心配なときは、カスタムドメインのゾーンに、CloudflareのWAFのレート制限ルールを足してください。WAFはWorkerより手前で働くので、Workerが呼ばれる前に止められます。
- Vercel版では、Vercelのファイアウォールで、Functionが呼ばれる前に止められます。使える機能は、プランによって違います。
- 管理画面のBANも、部屋のサーバーが呼ばれたあとに働きます。BANした送信元から呼ばれ続けるときは、上の2つで止めます。
気になることを見つけたら
脆弱性を見つけたときは、公開のIssueには書かず、GitHub Security Advisoryから非公開で知らせてください。