セキュリティリリースの複数脆弱性修正をmainブランチへチェリーピック
Railsのセキュリティリリースで対応した複数の脆弱性修正をmainブランチへ取り込むPRです。XSSインジェクション、パストラバーサル、Glob injection、DoSなど、Action Dispatch・Action View・Active Storage・Active Supportにまたがる広範な修正が含まれます。
背景
このPRは、セキュリティパッチリリース向けに作成されたコミット群を、開発ブランチであるmainへチェリーピックするものです。修正対象の脆弱性は複数のコンポーネントに及んでおり、それぞれ独立した攻撃ベクタを持ちます。
修正された脆弱性は以下の5領域にわたります:
- Action Dispatch: 開発者向けエラーページでのXSSインジェクション
- Action View: タグヘルパーへの空白属性名注入によるHTMLインジェクション(HackerOne #3078929)
- Active Storage (DiskService): パストラバーサルおよびGlob injection
- Active Storage (Streaming): バイトレンジリクエストを悪用したDoS
-
Active Support:
SafeBufferのhtml_safe?状態の伝播バグおよび科学的記数法文字列の不正変換
各修正は互いに独立しており、それぞれ固有のコードパスで修正が完結しています。
技術的な変更
Action Dispatch: エラーページのXSS修正
開発者向けエラーページで例外メッセージをコピーボタン用の <script> タグに埋め込む際、raw ヘルパーを使っていたためXSSが可能でした。
変更前:
<script type="text/plain" id="exception-message-for-copy"><%= raw @exception_message_for_copy %></script>
変更後:
<script type="text/plain" id="exception-message-for-copy"><%= @exception_message_for_copy %></script>
raw を除去してデフォルトのHTMLエスケープを有効にすることで、x</script><script>alert(1)</script> のような例外メッセージがページ中でスクリプトとして実行されなくなります。テストでは /xss_error エンドポイントから raise "x</script><script>alert(1)</script>" を発生させ、レスポンスボディに <script>alert(1)</script> が含まれず <script>alert(1)</script> にエスケープされることを確認しています。
Action View: 空白属性名の無視によるHTMLインジェクション修正
tag_helper.rb の tag_options メソッドが、空文字列や nil をキーとする属性を受け入れていたため、"" => "/onerror=alert(1)" のような入力で不正な属性を生成できました。
変更前:
options.each_pair do |key, value|
type = TAG_TYPES[key]
if type == :data && value.is_a?(Hash)
value.each_pair do |k, v|
next if v.nil?
変更後:
options.each_pair do |key, value|
next if key.blank?
type = TAG_TYPES[key]
if type == :data && value.is_a?(Hash)
value.each_pair do |k, v|
next if k.blank? || v.nil?
トップレベルの属性キー、data-*、aria-* のすべての階層で blank? チェックを追加しています。Nokogiriを使った統合テストで、空キー付き img タグのパース結果に src 属性のみが存在することを確認しています。
Active Storage DiskService: パストラバーサルおよびGlob injection修正
DiskService#path_for を全ファイルシステム操作のセキュリティゲートウェイとして再設計し、以下の多段防御を実装しました。
def path_for(key)
if key.blank?
raise ActiveStorage::InvalidKeyError, "key is blank"
end
begin
if key.split("/").intersect?(%w[. ..])
raise ActiveStorage::InvalidKeyError, "key has path traversal segments"
end
rescue Encoding::CompatibilityError
raise ActiveStorage::InvalidKeyError, "key has incompatible encoding"
end
begin
path = File.expand_path(File.join(root, folder_for(key), key))
rescue ArgumentError
raise ActiveStorage::InvalidKeyError, "key is an invalid string"
end
# ...(rootの外側チェックが続く)
end
検証の順序は、(1)空キーの拒否、(2)./..セグメントの拒否(エンコーディングエラーも捕捉)、(3)Null byteによるArgumentErrorの捕捉、(4)File.expand_path後のrootからの逸脱チェック、という多層構造です。新たに定義した ActiveStorage::InvalidKeyError が全パターンで一貫して送出されるようになり、従来のArgumentErrorやEncoding::CompatibilityErrorが漏れ出す問題も解消されます。
delete_prefixed ではGlob metacharacterのエスケープを追加しています。
escaped = escape_glob_metacharacters(prefix_path)
Dir.glob("#{escaped}*").each do |path|
FileUtils.rm_rf(path)
end
DiskController でも show と update の両アクションで InvalidKeyError をrescueし、それぞれ :not_found と :unprocessable_content を返すようになっています。
Active Storage Streaming: バイトレンジDoS対策
HTTP Range Requestsを悪用した大量データ転送によるDoSを防ぐため、2つの制限が追加されました。
streaming_chunk_max_size(デフォルト: 100.megabytes)は、リクエストされたレンジの合計バイト数の上限です。streaming_max_ranges(デフォルト: 1)は、1リクエストに含められるレンジの最大個数です。
def ranges_valid?(ranges)
return false if ranges.blank? || ranges.all?(&:blank?)
ranges.sum { |range| range.end - range.begin } < ActiveStorage.streaming_chunk_max_size
end
streaming_max_ranges のデフォルトが 1 になったことで、既存の多バイトレンジを使うコードは config.active_storage.streaming_max_ranges を明示的に増やす必要があります。また、create_before_direct_upload! では filter_metadata により analyzed、identified、composed というシステム管理用メタデータキーをユーザー入力から除去するようになりました。
Active Support: SafeBufferのhtml_safe?伝播バグと科学的記数法の不正変換修正
SafeBuffer#% メソッドが、unsafe状態のバッファでフォーマット操作を行った結果のバッファに @html_unsafe フラグを引き継いでいませんでした。mark_unsafe! という protected メソッドを新設し、* と % の両演算子でフラグを正しく伝播させています。
protected
def mark_unsafe!
@html_unsafe = true
end
number_to_delimited では、delimiter_pattern が nil の場合(区切り文字パターン未設定時)に手動でパーツを分割するフォールバックロジックを追加し、4桁・5桁の数値で区切りが正しく入らなかったバグを修正しています。また BigDecimal() への変換前に科学的記数法文字(d、e、E)を含む文字列を除外し、BigDecimal("123481223d98989") が巨大な数値に変換されてしまう問題を防いでいます。
設計判断
InvalidKeyError の一元化は、DiskService固有の防御設計として注目に値します。従来は ArgumentError や Encoding::CompatibilityError など複数の例外が混在しており、呼び出し側での網羅的なrescueが困難でした。単一の InvalidKeyError に統一することで、DiskController が明確なHTTPセマンティクスで応答できるようになっています。
path_for を「全ファイルシステム操作のセキュリティゲートウェイ」として明示的にコメントで定義し、新規メソッドが path_for または make_path_for を経由することを規約として文書化した点も、防御的設計の観点から重要な判断です。
streaming_max_ranges のデフォルトを 1 に設定した判断については、設定ガイドに「大多数のユースケースをカバーし、リトライも可能」と明記されており、破壊的変更であることを認識した上でセキュリティを優先した判断です。SafeBuffer#mark_unsafe! を private ではなく protected にした点は、同一クラス階層内からのサブクラスアクセスを許容しつつ外部からの呼び出しを防ぐ、Rubyの可視性設計に沿った選択です。
まとめ
本PRは、XSS・パストラバーサル・Glob injection・DoS・SafeBufferのフラグ伝播バグという複数の独立した脆弱性を、それぞれ最小限のコード変更で修正したセキュリティリリースのチェリーピックです。特にActive StorageのDiskServiceにおける多層防御の整備とInvalidKeyErrorへの統一は、今後の安全なストレージキー取り扱いの基盤となる設計変更といえます。