DiskService#path_for のパストラバーサル防止とエラーハンドリング強化

basecamp/activerecord-tenanted

Rails 8.1.2.1 で導入された CVE-2026-33195 対策を、activerecord-tenanted がオーバーライドしている ActiveStorage::Service::DiskService#path_for に移植しました。キーに不正なセグメントが含まれる場合は ActiveStorage::InvalidKeyError を即座に送出し、パスがルート外へ逃げ出すことを防ぎます。さらに、ArgumentErrorEncoding::CompatibilityError を同例外へ統一し、テナント構造特有の空セグメント検知も追加しました。

背景

上流 Rails が CVE-2026-33195 に対処し、DiskService#path_for にパストラバーサル検出とルート外チェックを実装しましたが、activerecord-tenanted ではテナントごとにパスを組み立てる独自ロジックが上書きされていました。そのため、上流の安全策が本gemでは適用されず、悪意あるキーがファイルシステム外へ書き込まれるリスクが残っていました。PR はこのギャップを埋め、上流の防御をテナント対応版にも反映させることが目的です。

技術的な変更

Rails バージョン要件の引き上げ: Gemfile.lockactiverecord-tenanted.gemspec の依存記述を >= 8.1.2.1 に変更し、InvalidKeyError とハードニング済み path_for が利用可能になるようにしました。

DiskService#path_for のロジック強化: 以下のコード差分で主要な防御が追加されました。

@@
-          if ActiveRecord::Tenanted.connection_class && key.include?("/")
-            tenant, key = key.split("/", 2)
-            File.join(root, tenant, folder_for(key), key)
-          else
-            super
-          end
+          return super unless ActiveRecord::Tenanted.connection_class && key.include?("/")
+
+          if key.split("/").intersect?(%w[. ..])
+            raise ActiveStorage::InvalidKeyError, "key has path traversal segments"
+          end
+
+          tenant, key = key.split("/", 2)
+
+          if tenant.blank? || key.blank?
+            raise ActiveStorage::InvalidKeyError, "key has a blank segment"
+          end
+
+          begin
+            path = File.expand_path(File.join(root, tenant, folder_for(key), key))
+          rescue ArgumentError
+            raise ActiveStorage::InvalidKeyError, "key is an invalid string"
+          end
+
+          unless path.start_with?(File.expand_path(root) + "/")
+            raise ActiveStorage::InvalidKeyError, "key is outside of disk service root"
+          end
+
+          path
+        rescue Encoding::CompatibilityError
+          raise ActiveStorage::InvalidKeyError, "key has incompatible encoding"

テストケースの追加: test/unit/storage_test.rb にパストラバーサル、空セグメント、NULL バイト、エンコーディング不整合など様々な攻撃ベクトルに対して InvalidKeyError が発生することを検証するテストが40行追加され、正常ケースでもルート内にパスが収まることを確認しています。

設計判断

この PR では 例外統一入力検証の徹底 を選択し、InvalidKeyError へすべての不正キーを集約しました。上流 Rails が導入した例外型は、開発者が期待するエラーハンドリングを統一できるため、テナントロジックでも同様に採用するのが自然です。また、return super でテナントが無いケースは既存ロジックへ委譲し、既存利用者への互換性を保ちつつ最低限の変更に留めました。要件バージョンの引き上げは、pre‑release ソフトウェアであることを踏まえて安全に実施できると判断されています。

まとめ

activerecord-tenantedDiskService#path_for に対し、上流 Rails のパストラバーサル防止策と例外統一を取り込み、テナント固有の空セグメント検査も追加しました。結果として、悪意あるキーからのディスク書き込みリスクが大幅に低減し、エラーハンドリングが一元化されました。Rails のバージョン要件を上げたこと以外は既存 API を壊さず、堅牢性向上のみを実現しています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
7ee543bb

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文・背景・技術的詳細・設計判断・まとめの5部構成が明確で、総論→各論→結論の流れが保たれている。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックは正しい `ruby:filepath` 形式で記載されている。一方、PRリンクは `[PR #307](URL)` となっており、仕様の `[ #307 ](URL)` 形式と完全に一致していないため警告。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

専門的なRails/ActiveStorageの知識を前提とした記述で、初心者向けの余計な説明はなく、対象読者に適合している。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論・各論・結論の段落構成になり、トピックセンテンスが先頭に配置。1段落1トピックで、段落長も6文以内に収まっている。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内コードブロックは提供されたDiffと一致しており、追加・削除箇所が正確に反映されている。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

InvalidKeyError、path traversal、Encoding::CompatibilityError などの用語はPRと一致し、誤用はない。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的主張(例外統一、パス検証のロジック)はPRの説明・Diffと整合性が取れている。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

全ての事実はPRタイトル・説明・Diffで裏付けられており、推測や根拠のない記述はない。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号 #307、Rails バージョン 8.1.2.1 など数値は正確。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルはPRの「path traversal in `DiskService#path_for`」を適切に日本語化しており、一致している。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

LTS、EOL、リリース日程等の外部情報は記述されておらず、PR情報に限定した内容になっている。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現は使用されておらず、PRの記述と整合性の問題もなし。