rails-security-multitenancy について
セキュリティを重視する Rails 開発とテナント境界のレビュー向けスキルです。Fizzy と Campfire の実践をもとに、テナント分離、認証、認可、SSRF 対策をまとめています。
主な機能
- 01テナントとユーザーでクエリを限定
- 02認証とレート制限の強化
- 03ユーザー指定 URL への防御
- 04ブロードキャストとセッションの分離
- 05CSRF、キャッシュ、認可への対応
活用シーン
- 01マルチテナント Rails のデータアクセスをレビュー
- 02マジックリンクや API トークン認証を設計
- 03Webhook やプッシュ URL を保護
- 04テナント間の配信とストレージ認可を確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ marckohlbrugge/ 37signals-skills --skill rails-security-multitenancyインストール後、Claude Code で/rails-security-multitenancyを入力して読み込まれたか確認します。
詳細
Rails のセキュリティとマルチテナント
セキュリティに慎重を要する Rails の作業や、テナント境界のレビューに使用します。Fizzy(パスベースのマルチテナント SaaS)と Campfire(シングルテナント、ボット API)のパターンを紹介します。
基本ルール
- すべてのテナントデータへのアクセスを、テナント/ユーザーの所有権境界でスコープします。
- テナントを考慮するフローでは、
Model.find(params[:id])をそのまま信頼しないでください。 - リアルタイムのブロードキャストとストリーム名を、テナント/アカウントでスコープします。
- 認証エンドポイントや不正利用されやすいエンドポイントにレート制限を設定します。
- ユーザーが指定した URL は信頼できない入力として扱います。
- アクセスを証明できない場合は、アクセスを拒否します(
head :forbidden)。
テナントアーキテクチャ(パスベース、Fizzy 方式)
- ミドルウェアが
PATH_INFOからアカウントのプレフィックスをSCRIPT_NAMEに抽出し、アカウントを読み込み、リクエストをCurrent.with_account(account)でラップします。URL ヘルパーは、ActiveStorage やscript_name:を使った Webhook ペイロード内の URL も含め、テナントに適した状態を自動的に保ちます。 - 3 層の認証:Identity → Session → アカウントスコープの User。 セッションは
Identityに紐づきます。各リクエストでidentity.users.find_by(account: Current.account)によりCurrent.userを解決します。アカウント A の有効なセッションがアカウント B で利用されることは決してありません。ActionCable のConnection#connectでも同じ解決処理を適用します。 Currentのセッターは連動します。sessionを設定するとidentityが解決され、identityを設定すると現在のアカウントに対するuserが解決されます。- 認証ルート(ログイン、サインアップ、マジックリンク)では、テナントスコープを明示的に無効化(
disallow_account_scope)し、テナントプレフィックス付き URL からリダイレクトします。 - 同時に複数のテナントへのログインをサポートする場合は、セッション Cookie のパスをアカウントのスラッグに設定します(
path: account.slug)。 - 定期ジョブはリクエストコンテキスト外で実行されるため、テナントを明示的に反復処理します(
with_each_tenant)。Current.accountをジョブのペイロードにシリアライズします(rails-jobs を参照)。 Rack::MockRequestを使ってテナントミドルウェアを単独でテストします。結合テストでは、フィクスチャのアカウントからdefault_url_options[:script_name]を設定します。
スコープ付き検索(多層防御)
パラメーターで選択するのは、すでに認可された集合内の どの レコードかであり、アクセス権を確立するものではありません。
@card = Current.user.accessible_cards.find_by!(number: params[:card_id]) # access graph
@membership = Current.user.memberships.find_by!(room_id: params[:room_id]) # join model
@user = Current.account.users.find(params[:user_id]) # tenant association
- シングルテナントのコードでも関連付け経由でスコープし、誤った ID は自然に 404 になります。
- 公開共有では、不透明なトークン(
Publicationレコードでのhas_secure_token :key)を使用し、内部 ID は使いません。 - ActiveStorage では、Blob をアカウントに紐づけ、ドメインモデルを通じて Blob/表現コントローラーを認可します(
blob → attachment → record.accessible_to?(user))。公開コンテンツには、明示的なpublicly_accessible?経路を設けます。 - アクセス権を取り消す際は、スコープ付きの非同期ジョブで派生データ(メンション、通知、ウォッチ)を削除し、境界を越えた状態が残らないようにします。
認証の強化
- マジックリンク/コードは、1 回限り(使用時に行を削除)、有効期間を短くし、
secure_compareで比較し、検証済みの保留中認証 Cookie を通じてメールアドレスに紐づけます。 - アカウントの有無を推測させないため、未知のメールアドレスにも実際のアカウントと同じ偽のフロー/UX を返します。
- API トークンには HTTP メソッド単位の権限を設定します(読み取り専用トークンでは POST できません)。生成したシークレットは有効期間の短いメッセージ検証器(約 10 秒)を使って一度だけ表示し、それ以降は表示しません。
- ボット/自動化の認証は明示的なモードにします。ボットキー認証の場合に限り CSRF をスキップし、デフォルトではすべての場所でボットを拒否します(
deny_botsとallow_bot_access only:)。 - Rails の組み込み機能でレート制限を設定し、レスポンスを各エンドポイントの仕様に合わせます。
rate_limit to: 10, within: 15.minutes, only: :create,
with: -> { redirect_to ..., alert: "Try again in 15 minutes." }
- セッションの記録処理の書き込みを抑制します(
last_active_atの更新は最大でも 1 時間に 1 回)。 - パスワード以外の機密パラメーターもフィルタリングします。メッセージ本文、プッシュ通知エンドポイント、トークンなどが対象です。
SSRF 防御の基本方針
Webhook、プッシュエンドポイント、URL プレビューなど、ユーザーの影響を受ける URL には、次の対策を適用します。
- リクエスト前に DNS を解決し、接続先 IP を検証します。ループバック、プライベート、リンクローカル、IPv4-mapped-IPv6 の範囲をブロックします(リンクローカルはクラウドのメタデータを指します)。
- DNS リバインディングを防ぐため、リクエストを検証済み IP に固定します(
Net::HTTP.new(host, port, ipaddr: resolved_ip))。 - 作成時と実行時の両方で検証します。
- リダイレクトの各ホップで、再解決して再検証します。リダイレクトチェーンは典型的な回避手段です。
- メモリ DoS を防ぐため、レスポンスサイズに上限を設けます(
content-lengthの事前チェックとチャンク読み込み時の上限)。 - 接続先の範囲が既知の場合は、許可リストを追加します(Web Push の場合、許可されたベンダーのホストサフィックスに加え、パブリック IP への解決を確認します)。
CSRF とキャッシュ
- フォームや CSRF トークンをレンダリングするページは、HTTP キャッシュに保存しないでください。
- オリジンチェックに加えて
Sec-Fetch-Site(same-origin/same-site)の検証を追加します。まずレポート専用モードで導入し、VaryヘッダーにSec-Fetch-Siteを追加します。 - 厳格な最低限のポリシー(
object_src :none、base_uri :none、frame_ancestors :self)を設定した CSP を適用します。適用前に、report_uriを指定したレポート専用モードで検証します。 - プライベートアプリでは、
X-Robots-Tag: noneを送信します。
認可のデフォルト
- モデルに述語メソッドを定義します(
card.editable_by?(user)、user.can_administer_board?(board))。コントローラーで確認し、head :forbiddenを返します。 - 散在する条件分岐ではなく、宣言的なコントローラーマクロ(
allow_unauthenticated_access、ensure_can_administer)を使用します。 - 共通のガードロジックは、小さな Concern に集約します。
不正利用への対応
- ユーザーを BAN する際は、関連する処理も実施します。ユーザーのセッション IP を BAN に変換し、ActionCable をリモートから切断し、セッションを削除します。コンテンツの削除は UI へのブロードキャストを伴う非同期処理で実行します。
- IP BAN は、データを変更するリクエスト(POST/PUT/PATCH/DELETE)にのみ適用します。
- BAN 対象がパブリック IP であることを検証します。ループバック/プライベート範囲を BAN できるようにしてはいけません。
- 無効化されたユーザーを切断します:
ActionCable.server.remote_connections.where(current_user: user).disconnect(reconnect: false)。
受信 Webhook
- 署名を検証し(例:
Stripe::Webhook.construct_event)、ペイロードの内容や順序を信用せず、送信元の API から正規の状態を再取得します。
危険信号
- テナントでスコープされていないブロードキャストチャンネルやストリーム名。
- 所有権を確認せず、リクエストパラメーターから推定されるテナントデータ。
- アカウントのスコープを適用せず、セッションからグローバルな
Userを検索する処理(ID とユーザーの混同)。 - 未検証の接続先への Webhook またはフェッチリクエスト。再検証せずにリダイレクトを追跡する処理。
- ドメイン認可を迂回する ActiveStorage URL。
- コントローラー全体に場当たり的な条件分岐として隠されたセキュリティ制御。
- 管理 UI で常時表示されるシークレット。
- レート制限のない認証エンドポイント。
コメント
Markdown 対応