KitchHike Tech Blog

KitchHike Product, Design and Engineering Teams

API開発で「意図せぬレスポンス」問題を防ぐ2つのコツ

こんにちは。KitchHikeエンジニアの小川です。 KitchHikeのアプリがつい先日リリースされました! 私は主にサーバサイドのAPI設計・開発を担当していたのですが、今回はその中で特に気を配った、意図しないレスポンスを防ぐためのAPI設計の取り組みについてご紹介したいと思います。

なお、本エントリはFood Service Engineers Meetup #3第6回スタートアップRails勉強会にて発表した内容を整理したものです。併せてご参照いただければと思います。

意図せぬレスポンス問題

API開発でよくぶつかる問題のひとつは、APIのレスポンスに意図しないデータ項目が含まれてしまうことです。たとえば user リソースをレスポンスとして返すケースを考えてみましょう。

Railsの場合、最もお手軽にjsonレスポンスを返すなら as_json でしょう。ここはひとつ、何も考えずにレスポンスを返してみましょう。

render json: @user.as_json

# => response
# {
#   "id": 1,
#   "first_name": "hike",
#   "last_name": "kitch",
#   ...
#   "password_digest": "xxxxxxxxx"
# }

なんとレスポンスに password_digest が含まれています! これはどう考えてもまずいです。

このように、非公開であるべきリソースがAPIレスポンスとして返されている状態は、公開APIであれば直ちにセキュリティ上の脆弱性となります。また、アプリ用の非公開APIであっても、クライアントにJSON形式等でデータが渡る以上、必ず避けるべきでしょう。

一方でこうした「意図せぬレスポンス」はちょっとしたミスにより発生しやすいものでもあります。

上記のケースは流石に極端ですが、たとえば「知らない間にDBのテーブルにカラムが追加されていた」ようなケースを想像してみると、容易に発生しうるものと考えられるのではないでしょうか。

意図せぬレスポンス問題の2つのパターン

この「意図せぬレスポンス」問題をもう少し深掘りすると、大体2つのパターンに分けることができます。

  1. そもそも公開してはいけないレスポンス項目を含めてしまった
  2. 特定のユーザー向けのレスポンス項目の公開範囲を誤った

以下ではこの2つの「意図せぬレスポンス」パターンに対して、KitchHikeがどのような設計アプローチで対処したかを説明していきます。

問題その1. そもそも公開してはいけないレスポンス項目を含めてしまった

この問題は、先ほどの as_json の実装例がよく示しています。

render json: @user.as_json

# => response
# {
#   "id": 1,
#   "first_name": "hike",
#   "last_name": "kitch",
#   ...
#   "password_digest": "xxxxxxxxx"
# }

jsonのシリアライズ時に、自分の意図しない、公開をしてはいけないレスポンス項目まで返してしまったパターンです。

好きこのんで、 password_digest をレスポンスに含めたい開発者はいないはずです。この問題の根本にあるのは、データ項目の非表示指定がブラックリスト方式だという点にあります。

render json: @user.as_json(except: [:address, :phone_number, ...])

as_json はデフォルトでモデルの全ての項目を返します。「この項目をレスポンスに含めたくない」という場合は、上記コードのようにそのことを明示的に指定しないと レスポンス項目に含めてしまいます。 したがって、開発者にモデルのどの項目を出さないべきかの漏れや認識違いがあれば、それが即レスポンスに含まれてしまうという、ミスが重大化しやすい問題をはらんでいるのです。

as_json ばかり槍玉にあげていますが、このことはブラックリスト方式を採用するシリアライズのライブラリであれば、言語問わず全てに言えることです。

問題その1の対策: レスポンスはホワイトリスト方式で指定する

こうした意図しないデータ項目のレスポンスを防ぐには、ブラックリスト方式の逆、ホワイトリスト方式でのレスポンス項目指定が効果的です。

ホワイトリスト方式での指定は、明示的にレスポンス項目を指定しない限り、レスポンスにその項目が含まれることはありません。したがって、仮にミスがあったとしても、それが公開されることはない、フェールセーフな仕組みとなるのです。

ホワイトリスト方式での指定をするgemには、たとえば以下のようなものがあります。

  • JbuilderRabl のようなテンプレートエンジン
  • ActiveModelSerializer

KitchHikeでは ActiveModelSerializer を採用しました。コード例はこんな感じです。

class UserSerializer < ActiveModel::Serializer
  attributes :id, :full_name

  def full_name
    "#{object.first_name} #{object.last_name}"
  end
end

# => response
# {
#   "id": 1,
#   "full_name": "hike kitch"
# }

ActiveModelSerializer では attributes にレスポンスに含めたい項目を指定します。指定していない項目はレスポンスには出てきません。これなら安心ですね!

このエントリでは深入りしませんが、 ActiveModelSerializer は記述がテンプレートエンジンのようなDSLではなく、Rubyコードなので、実装時は非常にテストがしやすく、また、 Concerns などRubyコードの抽象化プラクティスをそのまま適用できるという点で非常に使いやすいものでした。

補足: as_json のonly指定について

ちなみに as_json のコード例をご覧になって、 only を使えばいいのでは?と思った方がいらっしゃるかもしれません。

これは全くその通りです。 as_jsononly オプションでホワイトリスト方式の指定が可能です。

render json: @user.as_json(only: [:id, :first_name, :last_name, ...])

ですので、もし既に as_json を使っている場合でも、only を活用することで意図しないレスポンスに効果的に対処することが可能です。

ただ、個人的には as_json は、モデル側でオーバーライドした場合や、ネストしたモデルを扱う場合にやっぱりミスが起こりやすいと感じています。また、オプションを指定しないと全ての項目を返す時点で、根本において as_json はブラックリスト方式です。

したがって、選択の余地があるのならば、わざわざ as_json を使う理由はないと思っています。なお、この辺りの辛みについては、Thoughtbot社のエントリ "Better serialization, less as_json"によくまとまっているので、ぜひご参照ください。

問題その2. 特定のユーザー向けのレスポンス項目の公開範囲を誤った

この問題は、例えばログインしているユーザー自身にしか返してはいけない非公開のデータ項目が、誤って全てのユーザー向けのレスポンスに含めてしまった、というようなケースです。

よくあるパターンとしては、以下のようにクエリパラメータでレスポンスを出し分けるような場合でしょうか。

# /kitchens/:id
class KitchensController < ApplicationController
  def show
    if params[:scope] == "self"
       # ログインしているユーザー向けのレスポンス
    else
       # 全てのユーザー向けのレスポンス
    end
  end
end

上記コード例には、動作上、何の問題もありません。しかし、気になるのは条件分岐の箇所です。もしこの箇所にバグを作り込んでしまったら、ログインしているユーザー向けのレスポンスが誤って全てのユーザー向けのレスポンスとして返されてしまうかもしれません。

条件分岐のちょっとしたミスが、セキュリティ問題に直結してしまうというのは、コードの作りとして非常に脆く感じます。条件分岐の代替となる、より安全な出し分け方法が欲しいところです。

問題その2の対策: 非公開/公開リソースのエンドポイントを分割する

KitchHikeでは、この問題に対してそもそもエンドポイントを分けるというアプローチを取りました。ポイントは以下2点です。

  • クエリパラメータでの条件分岐ではなく、コントローラ自体を分ける
  • ログインユーザーのリソースは self のネームスペース配下に一元化

いうなれば、if文による分岐をコントローラのエンドポイントの分岐に置き換えたものです。

コードで示すと以下のようになります。

# /kitchens/:id
class KitchensController < ApplicationController
  def show
    # 全てのユーザー向けのレスポンス
  end
end

# /self/kitchens/:id
class Self::KitchensController < ApplicationController
  def show
    # ログインしているユーザー向けのレスポンス
  end
end

エンドポイント分割のメリット

このアプローチを取ることによって以下のようなメリットがありました。

  1. 条件分岐ミスのリスクを根本からなくせた
  2. エンドポイント分割によるスコープの明確化

この問題は、バグによる影響がセキュリティ上の問題に繋がる、非常に大きいものです。その点、このアプローチはそのリスクを根本から取り除けるという点で、非常に大きなメリットがあるものでした。

また、エンドポイントを分割したことで、スコープの切り分けがはっきりしました。そのことにより、開発者の間でもデータの公開範囲についての些細な認識違いや漏れが減り、コミュニケーションが非常に取りやすくなるというメリットもありました。

エンドポイント分割のデメリット

一方、このアプローチには以下のような気をつけるべき点もあります。

  1. APIコール数が増える
  2. 分割したコントローラでのコードの重複が起こりやすい

まず、エンドポイントが増えた関係上、APIをコールする回数は当然増えてしまいます。今後パフォーマンス上の問題が発生するのであれば、エンドポイント分割の原則を崩すのも必要になるかもしれません。ただ、現在のところ、N+1のコールにはならないので許容しています。

また、基本的に同じリソースへのアクセスとなるため、コードの重複が発生しやすくなります。この問題に対しては、モデル層にコードを寄せて、極力コントローラのコードを薄くすることで対応しています。

まとめ

以上、API開発において問題になりがちな「意図せぬレスポンス」問題について、KitchHikeでのアプローチをご紹介しました。整理すると以下のような感じでしょうか

  • API開発にあたっては以下のような2つの意図しないレスポンスの発生パターンがある
    1. そもそも公開してはいけないレスポンス項目の公開ミス
    2. 特定のユーザー向けのレスポンス項目の公開範囲設定ミス
  • 1.の課題に対しては、ホワイトリスト方式の指定が効果的
  • 2.の課題に対しては、エンドポイントを分割することで発生リスクをなくすことができる

正直なところAPI開発は試行錯誤の連続だったのですが、もし同じような課題にぶつかっていらっしゃる開発者の方にとって、今回ご紹介したKitchHikeでの事例が少しでも参考になれば幸いです。