RailsでArgumentError: invalid byte sequence in UTF-8が発生する場合の解決策
Railsで作成されたアプリケーションのリクエストURLにURLエンコード(壊れていてUTF8にデコードできない)された文字列を含めるとArgumentErrorが発生します。
再現方法
サンプルプロジェクトを構築
# railsの最新版をインストール $ gem install rails # プロジェクトのセットアップ $ rails new invalid-byte-sequence-in-utf8-demo $ cd invalid-byte-sequence-in-utf8-demo $ bundle install # 開発サーバ起動 $ rails server => Booting WEBrick => Rails 4.0.4 application starting in development on http://0.0.0.0:3000 => Run `rails server -h` for more startup options => Ctrl-C to shutdown server [2014-03-30 16:02:55] INFO WEBrick 1.3.1 [2014-03-30 16:02:55] INFO ruby 2.0.0 (2013-11-22) [x86_64-darwin13.0.0] [2014-03-30 16:02:55] INFO WEBrick::HTTPServer#start: pid=2000 port=3000
サンプルURL
# 正常なリクエストは成功
http://localhost:3000
# 不正な文字列を付与したリクエストは失敗
http://localhost:3000/?%8E%96%9B%26frac12%3Bae%9A%84%97%94%9F%9A%84%26sup3%3B%84ae%80
ArgumentError - invalid byte sequence in UTF-8:が発生する。
対応方法
この例外を無視する事もできるが解決する方法も2つある。
例外が発生するより前の段階で400 bad requestを返すようにする
このブログポストに書いてあり通り、Rackミドルウェアを追加してURLがUTF8にデコードできるかどうか検査して、できない場合は400エラーを返すシンプルな実装方法。
デコードできない文字列を無視して処理する
utf8-cleanerを使ってデコードできない文字列を無視してしまって、正常にデコード出来る文字列だけを処理する。
utf8-cleanerがやってる事
lib/utf8-cleaner/middleware.rb
1. Rackミドルウェアを追加してsanitize_envメソッドが各リクエストの前処理として呼び出される
2. sanitize_envメソッドではHTTP_REFERERやPATH_INFOといったURLに関連する環境変数のチェックをしている
3. 各環境変数に%が含まれる場合は後述のuri_string.rbで定義されているURIString#cleanedメソッドにその値を渡して実行している
module UTF8Cleaner class Middleware SANITIZE_ENV_KEYS = [ "HTTP_REFERER", "PATH_INFO", "QUERY_STRING", "REQUEST_PATH", "REQUEST_URI", "HTTP_COOKIE" ] def initialize(app) @app = app end def call(env) @app.call(sanitize_env(env)) end private def sanitize_env(env) SANITIZE_ENV_KEYS.each do |key| next unless value = env[key] if value.include?('%') env[key] = URIString.new(value).cleaned end end env end end end
utf8-cleaner/lib/utf8-cleaner/uri_string.rb
1. cleanedメソッドはまず渡された文字列がUTF8としでデコードできるか検査する
2. 文字列に問題なければなにもしない、問題がある場合は文字列を1文字ずつ検査
4. 文字が'%'の場合はその次とその次の文字をチェックして何バイトのUTF8の文字になるかをチェック
5. 不正な文字として判断されたらURL文字列から除かれる
6. 最終的に不正な文字列は除かれているのでArgumentErrorは発生しなくなる
module UTF8Cleaner class URIString attr_accessor :data def initialize(data) self.data = data end def cleaned if valid? data else encoded_char_array.join end end def encoded? data.include?('%') end def valid? valid_uri_encoded_utf8(data) end private # Returns an array of valid URI-encoded UTF-8 characters. def encoded_char_array char_array = [] index = 0 while (index < data.length) do char = data[index] if char == '%' # Skip the next two characters, which are the encoded byte # indicates by this %. (We'll change this later for multibyte characters.) skip_next = 2 # How long is this character? first_byte = '0x' + (data[index + 1] + data[index + 2]).upcase bytes = utf8_char_length_in_bytes(first_byte) # Grab the specified number of encoded bytes utf8_char_encoded_bytes = next_n_bytes_from(index, bytes) # Did we get the right number of bytes? if utf8_char_encoded_bytes.length == bytes # We did. Is it a valid character? utf8_char_encoded = utf8_char_encoded_bytes.join if valid_uri_encoded_utf8(utf8_char_encoded) # It's valid! char_array << utf8_char_encoded # If we're dealing with a multibyte character, skip more than two # of the next characters, which have already been processed. skip_next = bytes * 3 - 1 end end index += skip_next else # This was not an encoded character, so just add it and move to the next. char_array << char end index += 1 end char_array end def valid_uri_encoded_utf8(string) URI.decode(string).force_encoding('UTF-8').valid_encoding? end # Grab the next num_bytes URI-encoded bytes from the raw character array. # Returns an array like ['%E2', '%9C', '%93'] def next_n_bytes_from(index, num_bytes) return [] if data.length < index + (3 * num_bytes) num_bytes.times.map do |n| # Look for percent signs in the right places pct_index = index + (3 * n) if data[pct_index] == '%' byte = data[pct_index + 1..pct_index + 2] else # An expected percent sign was missing. The whole character is invalid. return [] end '%' + byte end end # If the first byte is between 0xC0 and 0xDF, the UTF-8 character has two bytes; # if it is between 0xE0 and 0xEF, the UTF-8 character has 3 bytes; # and if it is 0xF0 and 0xFF, the UTF-8 character has 4 bytes. # first_byte is a string like "0x13" def utf8_char_length_in_bytes(first_byte) if first_byte.hex < 'C0'.hex 1 elsif first_byte.hex < 'DF'.hex 2 elsif first_byte.hex < 'EF'.hex 3 else 4 end end end end
まとめ
utf8-cleanerを使うことでこのようなリクエストであっても正常に処理できるがそもそもリクエストされたURLを改変して処理するのでもはや処理する意味がない気がする。参考にさせてもらったブログの人がthis issue is not super-importantって言ってるのに同意でこの例外は無視してもいいし、気になる人は400エラーを返すのが一番綺麗なやり方かなと思う。
それでもこのようなリクエストを正常なレスポンスで返したい場合は自己責任でutf8-cleanerを使うといいと思う。