RailsでArgumentError: invalid byte sequence in UTF-8が発生する場合の解決策

Railsで作成されたアプリケーションのリクエストURLにURLエンコード(壊れていてUTF8にデコードできない)された文字列を含めるとArgumentErrorが発生します。

環境

ruby 2.0.0p353
rails-4.0.4

再現方法

サンプルプロジェクトを構築

# 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を使うといいと思う。