この記事はQiitaに投稿した内容を一部加筆修正し、移行してきたものです。

昨今、フロントエンド(Webやネイティブアプリケーションなど)は巨大化および複雑化しており、HTMLやJavaScript、CSSなどを配信する通常のモノリシックなRailsアプリケーションでは対応が難しくなってきました。 このような状況も相まって、RailsをJSONなどを返すAPIサーバとして使用するケースが増えてきています。

Rails 5以降、rails-apiがRails本体に取り込まれることになり1、APIアプリケーションの作成が活発になると思われます。

Rails製のAPIサーバで、クライアントから不正なJSONがリクエストパラメータが送られ、パースできなかった問題に遭遇ました。 今回はその対策と調査の記録を残します。

対応方法

Rails 3系および4系とRails 5以降で対応方法が異なるため別々に解説していきます。

Rails 3系および4系の場合

config/initializers/ディレクトリのrescue_json_parse_errors.rbファイルに以下のようなミドルウェアを定義します。 ファイル名およびクラス名はこれにこだわる必要はなく、任意なものでかまいません。

class RescueJsonParseErrors
  def initialize(app)
    @app = app
  end

  def call(env)
    begin
      @app.call(env)
    rescue ActionDispatch::ParamsParser::ParseError => _e
      return [
        400, { 'Content-Type' => 'application/json' },
        [{ error: 'There was a problem in the your JSON' }.to_json]
      ]
    end
  end
end

ちなみにRails 3系の場合の場合は、以下のようにします。

class RescueJsonParseErrors
  def initialize(app)
    @app = app
  end

  def call(env)
    begin
      @app.call(env)
    rescue MultiJson::LoadError => _e
      return [
        400, { 'Content-Type' => 'application/json' },
        [{ error: 'There was a problem in the your JSON' }.to_json]
      ]
    end
  end
end

envはRackのenvであるため、env['HTTP_ACCEPT']の内容をもとにレスポンスのContent-Typeを変更したり、原因となったアクセス元であるRemote IPなどをログ出力したりできます。

その後、config/application.rbファイルに、先程定義したミドルウェアを差し込む処理を追加します。

module MyApp
  class Application < Rails::Application
    # パラメータをパースするミドルウェアの前に独自のミドルウェアのクラス名を文字列で差し込む
    config.middleware.insert_before ActionDispatch::ParamsParser, 'RescueJsonParseErrors'
  end
end

このようにすることで、リクエストのJSONパースができなかった場合、HTTP statusを400としつつJSONを返却できます。

なぜControllerで例外を捕捉しないのか

そもそもなぜこのような対応が必要なのでしょうか。

class ApplicationController < ActionController::Base
  rescue_from ActionDispatch::ParamsParser::ParseError do
     render json: { message: 'There was a problem in the your JSON' }, status: 400
  end
end

Railsで発生する例外を共通的に処理するのであれば、上記のようにControllerの基底クラスで例外を捕捉すればよいと思われるかもしれません。

しかし、上記のコードで同様の事象を再現しても例外は捕捉されません。 これはControllerで使用しているparamsのパース処理がControllerの処理に到達する前のミドルウェア層で行われており2 3、そのタイミングで例外が発生してしまいます。

ハマったところ

config/application.rbにミドルウェアの差し込みを行う際、

module MyApp
  class Application < Rails::Application
    config.middleware.insert_before ActionDispatch::ParamsParser, RescueJsonParseErrors.to_s
  end
end

上記のようにクラスを評価しつつ文字列可するとuninitialized constant RescueJsonParseErrorsとなってしまいました。

これは、config/application.rbが読み込まれた後にinitializers のファイルが読み込まれることが原因です。

そのため、クラス名の文字列にするか、config/application.rbRescueJsonParseErrorsクラスを定義してあるファイルを明示的にrequireする必要があります。

gemでの解決

解決可能なgemを調べたところ、

の2つが見つかりました。 しかし執筆現在、それぞれのgemはメンテナンスが行われていないため、適切に判断して使用することをお勧めします。

Rails 5以降の場合

Rails 5系では、このあたりの処理に修正が入り、Controllerでリクエストパラメータのパース例外を捕捉できるようになりました。

# Rails 5.2.2未満
class ApplicationController < ActionController::Base
  def process_action(*args)
      super
  rescue ActionDispatch::Http::Parameters::ParseError => _e
    render status: 400, json: { message: 'There was a problem in the your JSON' }
  end
end

# Rails 5.2.2以上
class ApplicationController < ActionController::Base
  rescue_from ActionDispatch::Http::Parameters::ParseError do |_e|
    render status: 400, json: { message: 'There was a problem in the your JSON' }
  end
end

Rails 5.0.0においてActionDispatch::ParamsParser::ParseErrorは非推奨となり、5.2.0で削除されました。 そのため、代替となるActionDispatch::Http::Parameters::ParseErrorを使用しています。

終わりに

このような処理を追加すると、どのような恩恵が得られるのでしょうか。ログインを行うAPIを提供するケースを考えてみましょう。

何かしらの理由でリクエストのJSONのパラメータがパースできなかった場合、ActionDispatch::Http::FilterParametersが適用されずメールアドレスやパスワードなどが平分でログに出力される自体が考えられます。 この処理を適用することで、リクエストパラメータの漏洩事故を防ぐことができます。

これに限らず、サーバはクライアントからのリクエストは信頼すべきではないため、適切にバリデーション処理を入れるべきでしょう。