ActiveModelの整数キャストに文字列長の上限を設定してDoS対策を強化
ActiveModel::Type::Integer が to_i を呼び出す前に文字列を切り詰めるようになり、非常に長い文字列を整数にキャストする処理がDoS攻撃のベクタになり得る問題を緩和します。
背景
Rubyの to_i は非常に長い文字列に対しても律儀に全体を走査するため、数MBの文字列を渡すと処理に時間がかかります。ActiveRecordはHTTPパラメータを整数カラムにキャストする際にこのパスを通るため、ユーザーが意図的に巨大な文字列を送信することでサーバーリソースを枯渇させられる可能性がありました。
PR本文に示されたように、以下のようなコードが脆弱なパターンです。
Post.where(id: params[:id]).first
params[:id] に5MBの数字文字列が渡されると、従来の実装ではキャスト時に文字列全体を走査していました。ユーザーがモデル側で入力長を検証することが本来の対策ですが、ActiveModel層でも自動的に上限を設けることで多層防御を実現します。
技術的な変更
activemodel/lib/active_model/type/integer.rb の cast_value メソッドが拡張され、文字列に対して to_i を呼ぶ前に _limit * 4 バイトへの切り詰めを行うようになりました。
変更前:
def cast_value(value)
value.to_i rescue nil
end
変更後:
def cast_value(value)
case value
when ::Integer
value
when ::String
str = value.bytesize > _limit * 4 ? value.byteslice(0, _limit * 4) : value
str.to_i rescue nil
else
value.to_i rescue nil
end
end
_limit はカラムのストレージサイズ(バイト数)です。デフォルトの4バイト整数では上限は16バイト、8バイトの bigint では32バイトになります。この値は、各型が表現できる最大値の桁数(4バイト整数で10桁、8バイト整数で19桁)を十分カバーしつつ、符号文字や短いスラグサフィックスのぶんの余裕も持たせた設計です。
また、値がすでに ::Integer である場合は to_i を呼ばずに直接返す最適化も合わせて導入されています。これにより、整数値のキャストではメソッド呼び出しのオーバーヘッドが削減されます。
追加されたテストは以下の2ケースをカバーしています。
- 上限を超える17バイトの文字列は先頭16バイトのみが
to_iの対象になること - 16バイト以下の文字列および
"42-some-slug"のようなスラグ形式は従来どおりキャストされること
設計判断
上限の係数として 4 が選ばれた理由は、ActiveRecordの「auto integerization(スラグの自動整数化)」との互換性維持にあります。
当初の検討では、データベースのカラム幅をそのまま上限にする案もありましたが、"123-hello-world-etc" のようなスラグ形式をパラメータとして受け取ってそのまま整数カラムの検索に使うパターンが広く使われているため、この案は採用されませんでした。_limit * 4 はスラグサフィックスを許容しつつも、数MBレベルの文字列を切り詰めるのに十分な上限です。
また、切り詰めが発生しても to_i は先頭の数値部分のみを解釈するRubyの標準的な挙動に依存しており、"1111111111111111" * N のような文字列が意図どおり先頭16バイトの数値として解釈されます。例外を発生させるのではなく、黙って切り詰めて継続する方針は、既存のエラーハンドリング(rescue nil)と一貫しています。
まとめ
本PRは、ActiveModelの整数キャストに軽量なバイト数チェックを加えることで、コードの変更を最小限に抑えながらDoSリスクを大幅に低減しています。スラグ互換性を維持するために _limit * 4 という係数を選んだ設計判断は、既存のユースケースを壊さずにセキュリティ境界を設けるバランスの取れたアプローチです。