要点

  • MistboardがRailwayに置いていた認証情報をすべてローテーションするには、9回のローテーション、22個の変数の削除、19時間にわたる約2時間の手作業が必要で、さらに自分で起こした12分間のデータベース障害もあった。
  • 最大のリスクはPostgresのパスワードだった。データベースに公開TCPプロキシがあったので、接続文字列が漏れていれば、インターネット上のどこからでも使えたはずだ。
  • 障害は、稼働中のコンテナがまだ古いパスワードを持っているうちにそのパスワードを変えたことで起きた。データベースのロールをもう1つ用意すれば新旧を並行して使えるので、同じローテーションをダウンタイムなしで行える。
  • フィンガープリントの照合で、次のデプロイを止めていたはずの古いリリーストークンを、どちらの値も表示せずに見つけた。

太平洋時間10月21日午後4時55分、「Urgent: Railway Security Disclosure - Rotate Credentials」という件名のメールがRailwayから届いた。最初の指示は「APIキーを含め、Railwayに保存しているすべての環境変数を直ちに失効させ、再発行すること」だった。

Mistboardは、私が一人で運営しているシャンチー(中国象棋)とその変種のサイトだ。Railway上で3つのサービスとして動いている。web(Nodeサーバーとサイト)、engine-worker(ボットの裏で動くゲームエンジン)、そしてPostgresだ。これらのサービスが使うシークレットは、すべてRailwayの環境変数だった。

Railwayは侵害を報告し、ビルドを1日止めた

Railwayの公表によると、同社は「顧客の環境変数を保存するシステムに関わる異常な活動を検知」し、権限のない第三者が「暗号化された環境変数にアクセスし、その一部については復号された内容を入手した可能性がある」。影響は「一部のユーザーに限られる可能性がある」とされ、該当者にはメールが届く。私に届いたのは一般向けの通知だったので、すべての値が漏れたものとして扱った。2日後に届いた2通目のメールで範囲が絞られた。私のワークスペースの変数は、攻撃者がAIやクラウドプロバイダーのAPIキーを探すのに使ったパターンに一致していたという。ただし、どれかが復号されたかどうかはRailwayにも分からないとのことだった。その時点で、ワークスペース内のシークレットはすべてローテーション済みだった。

ビルドの停止が、その後のすべてを左右した。稼働中のコンテナがまだ使っているキーを失効させると、新しいビルドが本番に出るまでその機能は壊れたままになる。そして丸一日近く、新しいビルドは本番に出せなかった。

最大のリスクはデータベースのパスワードだった

すべてのシークレットを、それを手にした者がサイトやプレイヤーに何をできるかで順位づけした。

シークレット 手にした者ができること 調べた結果
Postgresのパスワード(公開TCPプロキシあり) どこからでも任意のテーブルを読み書きできる 追加されたロールなし、説明のつかない管理セッションなし
メールAPIキー 偽のサインインコードなど、mistboard.comとしてメールを送れる 送信ログに異常なし
LLM APIキー(フォーラムの翻訳) 私の負担で推論を実行できる 過去30日間のこのキーでの呼び出しは、すべてサーバーが保存した翻訳と一致
アナリティクスの個人APIキー フルAPIでサイトのアナリティクスを読める 再発行済み
非公開エンジンリポジトリのデプロイキー エンジンのソースを読める 差し替え済み、古いキーは削除
決済キー ほとんどない(課金は未開始) 再発行済み
内部サービストークン2つ サイト自身の内部エンドポイントを呼べる 再生成済み

各サービスはRailwayのプライベートネットワーク経由でデータベースにつながるが、データベースには公開ポートのTCPプロキシもあった。ノートPCから管理スクリプトを実行できるよう、有効のままにしていたものだ。このプロキシがあると、接続文字列はどのマシンからでも使えるログイン情報になる。削除はクリック1回で済み、本番には何の影響もなく、最初に手をつけたことの1つだった。今では管理スクリプトは、プライベートネットワークへの短時間のSSHトンネルを通してデータベースにつながる。

データベースをコピーされても、パスワードは含まれていない。サインインはメールで送るワンタイムコードで行い、サイトはサインインコードとセッショントークンをハッシュとしてしか保存しないので、読み取られた内容からプレイヤーとしてサインインする手段はない。

メールから最後の確認まで19時間

時刻(PDT) 誰 出来事
10月21日 00:24 Railway 最初の状況報告、ビルド停止
10月21日 04:33 Railway ダッシュボード、API、CLIが停止、全リージョンでビルド停止
10月21日 16:55 Railway 公表メール:すべてをローテーションせよ
10月21日 17:30 私 管理表を作成、コードが読むすべてのシークレットを棚卸し
10月21日 17:40 私 データベースを確認:追加されたロールなし、説明のつかない管理セッションなし
10月21日 18:04 私 LLMキーを無効化、次のデプロイまでフォーラムの翻訳を停止
10月21日 18:08 私 データベースの公開プロキシを削除、メール送信ログに異常なし
10月22日 00:10ごろ Railway 有料プランのビルドが再開、その後数時間はビルドが遅い状態
10月22日 10:31 私 アナリティクス、決済、Webhookのキーを各プロバイダで再発行
10月22日 10:38 私 新しい値8つを反映、webとengine-workerを再ビルド(それぞれ20分以上)
10月22日 10:52 私 使われていない変数22個を削除
10月22日 11:04 私 engine-workerのビルドが新しいデプロイキーでエンジンリポジトリをクローン、古いキーを削除
10月22日 11:23 私 データベースのパスワードを再生成、障害発生
10月22日 11:25 私 Postgres復旧、engine-workerが再起動時にクラッシュ
10月22日 11:35 私 webが新しいパスワードで稼働、障害終了
10月22日 11:38 私 engine-workerが新しいパスワードで稼働
10月22日 11:50 私 新しいメールキーでサインインコードが届き、古いキーを削除、ボット対局とフォーラムの翻訳も動作
10月22日 11:55 私 古いリリーストークンを発見して修正、ローテーション完了

手作業の時間は、その晩に約40分、翌朝に90分だった。最初の晩は、ビルドを必要としない作業に充てた。それ以外はすべて、ビルドの順番待ちだった。

ほとんどのプロバイダでは新旧を並行させた。メールキーは、新しいキー経由でサインインコードが届くまで有効のままにし、古いデプロイキーは、ビルドが新しいキーでエンジンリポジトリをクローンできてから削除した。例外はPostgresだった。

作業はClaude Codeで行った。Claudeは管理表をつけ、コードから棚卸しを作り、データベースとログの確認を実行し、スクリプトを書き、すべての確認を実行した。プロバイダ側の作業はすべて私がやった。各キーの再発行、新しい値のmacOS Keychainへの入力、プロキシの削除、変数の削除、そしてRegenerateを押すことだ。Claudeが見たのはシークレットの名前だけで、値は一度も見ていない。スクリプトが出力を伏せたまま各値をKeychainからRailwayへ運んだので、トランスクリプトにもログにもファイルにも、シークレットは一つも現れなかった。

データベースのパスワード変更で、データベースの読み取りが12分間すべて失敗した

RailwayのRegenerateボタンは、データベースのパスワードと変数を同時に変える。webとengine-workerがビルド済みのイメージから再デプロイされる間、1分ほど接続が失敗する程度だろうと考えていた。Railwayのドキュメントは違うことを書いている。再デプロイは「デプロイメントの元のソースコードからイメージを再ビルドする」。再起動は変数も含めて既存のデプロイメントを使い回すので、古いパスワードのまま戻ってくる。

Postgresは2分で正常に戻ったが、稼働中のコンテナからのクエリはすべて失敗した。サイト自体は落ちなかった。ページは表示されるが、データベースを読むものはすべて失敗した。進行中の対局は切れ、新しいwebのビルドが本番に出るまで、スタディ、フォーラム、チャット、対局ビューアはエラーを返した。Railwayのビルドキューはまだ障害から回復しきっておらず、障害はちょうど遅いビルド1回分の長さになった。古いパスワードはもう消えていたので、それより早く抜け出す方法はなかった。

ログイン用のロールをもう1つ用意すれば、同じローテーションをダウンタイムなしで行える。新しいロールを作り、サービスの接続先をそれに向けてデプロイし、新しいデプロイメントが正常になったら古いロールを無効にする。ビルドにどれだけ時間がかかっても、その間は新旧が並んで動く(#614)。

engine-workerが事態を悪化させた。Postgresが再起動したとき、ワーカーのデータベースクライアントは誰も受け取らないエラーを投げ、プロセスは再接続せずに終了した(#612)。データベースの再起動はよくあること(メンテナンス、フェイルオーバー、プラン変更)なのに、ワーカーにそれを経験させたテストは一つもなかった。

フィンガープリントの照合で、次のリリース前に古いトークンを見つけた

リリースでは再起動の前に進行中の対局を退避させる(ドレイン)が、そのドレインの呼び出しにはトークンが要る。リリーススクリプトはKeychainをまず古い項目名で探し、次に正式な名前で探していた。ローテーションでは新しい値を正式な名前で書き込み、古い項目は残したままだったので、次のリリースは古いトークンを送り、401を受け取り、ドレインできなかったはずだ。

9月にリリースが2回、ドレインの401で失敗したあと、drain:token-checkを書いた。リリースが読む順にトークンのローカルコピーすべてのフィンガープリントを取り、本番コンテナの中の値と照合する。どの値も表示はしない。次の定期リリースの4時間前、このチェックはトークンが隠れていると報告した。本番の値はノートPCにあるが、それより先に古いコピーが読まれる状態だった。古い項目を削除して直った。

チェックを書いたとき、名前が2つあることは知っていた。検出器を作りながら危険そのものは残し、ローテーションがそれを再現した。直し方は名前を1つにすることで、ローテーションのたびにプッシュ用スクリプトがチェックを実行するようにする(#613)。

22個の変数が使われていなかった

最初の棚卸しはコードから作った。サーバーとスクリプトが読むすべての環境変数名だ。ダッシュボードは別の実態を示していた。22個の変数が使われておらず(廃止したゲーム変種のフラグや、もうビルドしていないエンジンのインストール用スイッチ)、コードが読むシークレット名のうち3つはどこにも設定されていなかった。機能フラグは今も約30組残っている。ゲーム変種ごとに1組ずつ、両方のサービスにある。9つのシークレットを見つけるために、すべての変数を名前で読んだ。

CIにはローテーションするものがなかった。本番を呼び出す定期ワークフローは有効期間の短いGitHub OIDCトークンで認証し、それをサーバーが検証するので、リポジトリのシークレットに認証情報は置いていない。

これから守る7つのルール

  1. すべての認証情報を重ねる。 古い認証情報が失効する前に、新しいものが使える状態にする。データベースを含め、すべてのプロバイダでそうする。
  2. 切り替えの前に再デプロイの時間を計る。 古い認証情報が先に失効すれば、ビルド時間がそのまま障害時間になる。プラットフォームが「再デプロイ」と「再起動」で何を指しているかを読み、両方の時間を計る。
  3. 長時間動くプロセスは、データベースの再起動を生き延びること。 クライアントのソケットを切断し、次のクエリが成功することを確かめるテストを書く。
  4. シークレットは1つの名前で保存する。 古い名前の2つ目のコピーは、いずれ先に読まれる。値を表示できない場所ではフィンガープリントを比べ、その比較はローテーションを行うツールの中で実行する。
  5. シークレットの一覧は短くし、設定とは分ける。 ローテーションで読むのは9項目の一覧であるべきだ。機能フラグはコードか単一の一覧に置く(#615)。
  6. 誰も必要としない受信経路は閉じる。 データベースの公開プロキシは管理スクリプトの手間を1つ省いたが、そのせいでデータベースは何か月も、パスワード1つを隔ててインターネットから到達できる状態だった。
  7. 保存しない認証情報を選ぶ。 OIDCのおかげで、CIのローテーションはゼロだった。

次の一歩は、時間を計ったリハーサルだ。Postgresのダンプを別のホストに復元し、それに対してwebを起動する。そうすれば、別の場所での復旧にかかる時間がわかっている数字になる。