岩本隆史の日記帳(アーカイブ)

はてなダイアリーのサービス終了をうけて移行したものです。更新はしません。

Rails 2系のXSS脆弱性がRuby 1.9では影響なしとされる理由

Rails 2系のXSS脆弱性を修正するパッチが先日公開されました。

4日(米国時間)、Ruby on Railsの2系すべてのバージョンにXSS脆弱性があることがRiding Rails: XSS Vulnerability in Ruby on Railsにおいて発表された。特定のUnicode文字列を使ってチェックをくぐり抜け、任意のHTMLを送り込まれる危険性がある。なおRuby 1.9系で動作しているアプリケーションはこの影響を受けない。

http://journal.mycom.co.jp/news/2009/09/07/048/index.html

この件に関して、大垣さんは次のように説明しています。

RoR脆弱性に関連してRuby1.9では安全、と解説されていますが、それはRuby1.9は不正な文字エンコーディングを受け付けないからです。

何故かあたり前にならない文字エンコーディングバリデーション – yohgaki's blog

しかし、この説明は私には腑に落ちませんでした。「不正な文字エンコーディングを受け付けない」ならば、なぜ String#valid_encoding? などというメソッドが存在するのでしょうか?

私はRailsにはあまり興味がないのですが、XSS脆弱性の対策には興味があるため、Railsの挙動を調べてみました。

パッチによる修正点

調査した環境はRails 2.3.3です。同環境用のパッチを適用すると、ActionView::Helpers::TagHelper モジュールのインスタンスメソッド・escape_once が、下記のように修正されます。

修正前
def escape_once(html)
  html.to_s.gsub(/[\"><]|&(?!([a-zA-Z]+|(#\d+));)/) { |special| ERB::Util::HTML_ESCAPE[special] }
end
修正後
def escape_once(html)
  ActiveSupport::Multibyte.clean(html.to_s).gsub(/[\"><]|&(?!([a-zA-Z]+|(#\d+));)/) { |special| ERB::Util::HTML_ESCAPE[special] }
end

String#gsub による置換処理の前に、まず ActiveSupport::Multibyte.clean を通すわけです。

この ActiveSupport::Multibyte.clean もパッチ適用により追加されるメソッドで、定義部分は下記のとおりとなります。

if 'string'.respond_to?(:force_encoding)
  # Removes all invalid characters from the string.
  #
  # Note: this method is a no-op in Ruby 1.9
  def self.clean(string)
    string
  end
else
  def self.clean(string)
    if expression = valid_character
      stripped = []; for c in string.split(//)
        stripped << c if valid_character.match(c)
      end; stripped.join
    else
      string
    end
  end

Ruby 1.9(正確には String#force_encoding が定義済み)の場合には、引数をそのまま返します。Ruby 1.8の場合は valid_characterメソッドの戻り値(多くの場合、UTF-8文字列の正規表現)とのマッチングを行い、該当する文字のみ残します。サニタイズです。

つまり、Ruby 1.8の環境にパッチを適用すると、escape_onceメソッドでの出力時に、不正バイトがサニタイズされるようになる、ということです。

入口ではバリデーションされない

大垣さんの前掲記事でも、徳丸さんの記事でも、入口でのバリデーションが推奨されていて、私もだんだんその見解になびいてきているのですが、それはともかくRailsでは、入口でのバリデーションは行われません。これは、パッチを当てても変わりませんでした。

Ruby 1.8(パッチ適用前)のスクリーンショット

そんなわけで、不正バイトを含むデータを無事(?)登録し、パッチ適用前にデータ編集画面を参照すると、下記のようになりました。

どのフィールドもサニタイズされていません。

Ruby 1.8(パッチ適用後)のスクリーンショット

パッチを適用すると、こうなりました。

input要素のvalue属性値がサニタイズされています。textareaの内容については、escape_onceメソッドを通らないためサニタイズされません。

「これでいいのか」というのが正直な感想ですが、「これでいいのだ」ということなのでしょう。

Ruby 1.9スクリーンショット

さて、問題のRuby 1.9ではこうなりました。

先に見たとおり、パッチを当てても当てなくても、この挙動は変わりません。ステータスコードは「500 Internal Server Error」です。XSSにはならないので修正不要、ということなのでしょう。

画像をよく見ると、etag= というメソッドを経由して「invalid byte sequence in UTF-8」エラーが発生しているのが分かります。これはなぜでしょうか。

なぜRuby 1.9でエラーが起きるのか

ActionController::Response#etag= メソッドの定義は下記のとおりです。

def etag=(etag)
  if etag.blank?
    headers.delete('ETag')
  else
    headers['ETag'] = %("#{Digest::MD5.hexdigest(ActiveSupport::Cache.expand_cache_key(etag))}")
  end
end

調べると、このメソッドに渡される引数はレスポンスボディ文字列(HTML)でした。レスポンスボディがごっそり渡され、そのMD5ダイジェストがETagヘッダとして使われます(ActiveSupport::Cache.expand_cache_key は追っていません)。

さて、ここで etag.blank?、すなわち String#blank? の定義を見てみましょう。これは、active_support/core_ext/blank.rb によって追加されるメソッドです。

class String
  def blank?
    self !~ /\S/
  end
end

正規表現によるマッチングが行われています。なんと、ここで例外が発生するのです!(このRuby 1.9の挙動については、徳丸さんの前掲記事で指摘されているとおりです)

試しに:

class String
  def blank?
    self == ''
  end
end

としたら例外は起きず、Ruby 1.8(パッチ適用前)のスクリーンショットと同じ状態になりました。

結論

RoR脆弱性に関連してRuby1.9では安全、と解説されていますが、それはRuby1.9は不正な文字エンコーディングを受け付けないからです」というのは誤りで、「ETag生成処理時にたまたま正規表現マッチが行われ、Ruby 1.9ではそこで例外が発生するから」というのが正解です。ますますRailsを使う気が失せました。

「ますますRailsを使う気が失せ」た理由(2009-09-25追記)

説明もなしに「ますますRailsを使う気が失せました」と書いてしまいましたが、そう書いた理由は単純で、「例外が起きるから放っておこう」という(ふうにみえる)メンテナの態度は、信頼するに値しないと私には思われるからです。Ruby 1.9でも ActiveSupport::Multibyte.clean でサニタイズなりバリデーションなりしたほうが、XSS対策のうえでも保守のうえでも、放っておくより適切だと思います。

「だったらお前が別のパッチ提案しろよ」といわれるかもしれませんが、使ってもいなければ興味もないもののパッチを書くくらいなら他のことを優先させたいので、ごめんなさい。