はじめに
旅行記録アプリ(Rails の API + Nuxt の SPA)を公開デモとして作り直すことになり、その前の見直しとして、API のレスポンスを curl で1つずつ眺めていました。
このアプリでは、ログイン画面で「そのメールアドレスが登録済みかどうか」を外から推測されないよう、少し手間をかけた対策を入れていました。
ところが、ログインしなくても取得できる旅行一覧の API のレスポンスに、ほかのユーザーのメールアドレスが含まれていました。
"user": { "id": 2, "display_name": "佐藤花子", "email": "hanako@example.com" }
画面にはメールアドレスを一切表示していなかったので、ブラウザで操作しているだけでは気づけませんでした。
なお、このアプリは以前、数日だけ公開していた期間があり、その間も同じ状態でした(その環境は現在撤収済みです)。
先に結論:API が、そのエンドポイントの機能に必要のない email まで返していました。返す項目を明示的に絞り、「レスポンスのどこにも email が無いこと」をテストで固定して直しました。
環境
- Ruby 3.4.9
- Ruby on Rails 8.1.3(発見時。修正時は 8.1.4 に更新済み)
- MySQL 8.0
- フロントエンド: Nuxt 4(SPA)
- 確認: curl / Chrome DevTools
症状
ログインせずに、いくつかの API を叩いてみました。
curl -sS "http://localhost:3010/api/v1/trips?sort=recent" | grep -o '"email":"[^"]*"' | head -3
"email":"hanako@example.com"
"email":"jiro@example.com"
調べると、他人のメールアドレスを返していた場所が 5 つありました。
| API | 誰のアドレスが含まれていたか | ログイン |
|---|---|---|
| 旅行一覧 | 投稿者 | 不要 |
| 旅行詳細 | 投稿者・コメントを書いた人 | 不要 |
| フォロワー/フォロー中の一覧 | 一覧に並ぶ全員 | 不要 |
| タグ別の旅行一覧 | 投稿者 | 不要 |
| お気に入り一覧 | 投稿者 | 必要 |
一方、ログイン画面では次の 2 つの対策をしていました。
- メールアドレスとパスワードのどちらが違っても、同じエラーメッセージを返す
- 登録されていないメールアドレスでも、ダミーのパスワード照合を走らせて、ユーザーの有無による処理時間の差を小さくする
エラーメッセージを統一する考え方は、Rails のセキュリティガイドでも説明されています。応答時間の差によってユーザーの有無が推測されることについては、OWASP の Authentication Cheat Sheet でも注意されています。
どちらも「このアドレスは登録済みか」を推測させないための対策です。しかし、別の公開 API から一部でもメールアドレスを集められるなら、ログイン画面で行っていたこれらの対策の効果を大きく損ないます。
切り分け:どこで含まれていたのか
まず、コントローラーのどこで email を返しているかを探しました。
grep -rn "email" backend/app/controllers | grep -v "params\|auth_controller"
comments_controller.rb: user: { id: c.user.id, display_name: c.user.display_name, email: c.user.email }
favorites_controller.rb: user: { id: trip.user.id, display_name: trip.user.display_name, email: trip.user.email },
follows_controller.rb: email: u.email,
tags_controller.rb: user: { id: trip.user.id, display_name: trip.user.display_name, email: trip.user.email },
trips_controller.rb: email: user.email,
フォロワー一覧とフォロー中の一覧は、同じ組み立て処理(user_summary)を通っていました。
次に、フロントエンドが他人の email を使っているかを確認しました。
grep -rn "\.email" frontend/app | grep -v -E "auth|login|signup"
結果は 0 件でした。他人の email は、どのクライアントからも使われていないのに返し続けていたことになります。
原因:返す項目を「その API の機能に必要かどうか」で選んでいなかった
各コントローラーで、レスポンスのハッシュ(user: { ... })を手で組み立てていました。そのとき「ユーザーの基本的な項目」として、email も一緒に入れてしまっていました。
画面に出ていなくても、ブラウザの開発者ツールや curl を使えば、レスポンスの中身は誰でも見られます。
OWASP API Security Top 10 では、これは API3:2023 Broken Object Property Level Authorization として整理されています。対策として、to_json のような汎用的な方法に頼らず返すプロパティを選ぶこと、レスポンスを機能上の要件に必要な最小限にすることが挙げられています。今回の修正も、それに合わせて公開 API から不要な email を外しました。
解決:返す項目から外し、テストで固定する
1. 5 か所から email を外す
def comment_payload(c)
{
id: c.id, body: c.body, created_at: c.created_at,
- user: { id: c.user.id, display_name: c.user.display_name, email: c.user.email }
+ user: { id: c.user.id, display_name: c.user.display_name }
}
end
ほかの 4 か所も同じように外しました。ただし、ログイン中の本人の情報を返す /me には email を残しています。本人がプロフィール画面で自分のアドレスを確認する、という機能に必要だからです。
2. 「レスポンスのどこにも email が無い」ことをテストにする
レスポンスは入れ子が深いので、ハッシュと配列をたどって、email というキーが1つも無いことを確かめるヘルパーを用意しました。
test "未ログインで取得できる API は email を返さない" do
[
"/api/v1/trips",
"/api/v1/trips/#{trips(:alice_kyoto).id}",
"/api/v1/tags/#{ERB::Util.url_encode('京都')}",
"/api/v1/users/#{users(:alice).id}/follows?type=followers",
"/api/v1/users/#{users(:bob).id}/follows?type=following"
].each do |path|
get path
assert_response :ok
assert_no_email_key JSON.parse(response.body), path
end
end
private
# レスポンスの入れ子をすべてたどり、"email" キーが 1 つも無いことを確かめる。
def assert_no_email_key(node, path)
case node
when Hash
refute node.key?("email"), "#{path} が email を返している: #{node.inspect.truncate(120)}"
node.each_value { |v| assert_no_email_key(v, path) }
when Array
node.each { |v| assert_no_email_key(v, path) }
end
end
テストの準備では「bob が alice をフォローする」関係を作っています。そのため、alice のフォロワー一覧にも、bob のフォロー中の一覧にも、確かめる対象のユーザーが入ります(空の一覧を確かめても意味がないので、ここは気をつけました)。
あわせて、消しすぎていないことも確認しています。
test "favorites は他人の email を返さず、/me は本人の email を返す" do
login_via_api(users(:alice))
post "/api/v1/trips/#{trips(:bob_okinawa).id}/favorite"
get "/api/v1/favorites"
assert_no_email_key JSON.parse(response.body), "/api/v1/favorites"
get "/api/v1/me"
assert_equal users(:alice).email, JSON.parse(response.body).dig("user", "email")
end
テストは修正前に先に書いて、失敗することを確認してから直しました。直したあとは、全体で 273 件のテストがすべて通りました。
なお、今回追加したのは email に対する回帰テストです。たとえば将来、電話番号のような別の項目をうっかり返してしまっても、このテストでは気づけません。API 全体としては、レスポンスのスキーマを定義して、返してよい項目そのものを固定する方法も検討できます。
3. 直ったことを確認する
curl -sS "http://localhost:3010/api/v1/trips?sort=recent" | grep -c '"email"'
0
フロントエンドは他人の email を使っていなかったので、画面側の修正は不要でした。
教訓
-
「画面に出ていない」と「API が返していない」は別物です。公開前に一度は、開発者ツールの Network タブや
curlで、生のレスポンスを見ておくと安心です - ログイン画面で「アドレスを推測させない」対策をするなら、ほかの API がアドレスを返していないかもセットで確認する必要があります
- API のレスポンスは、そのエンドポイントの機能に必要な項目だけを明示的に返します。迷った項目は、安易に追加しないようにします
- 「キーが無いこと」は、入れ子をたどるテストで固定しておくと、あとから項目を足したときにも気づけます。本人向けの API には残っていることも確かめておくと、消しすぎを防げます
おわりに
ログイン画面ではかなり気を配っていたつもりだったのに、「一覧の API」というまったく別の入口からメールアドレスを取得できる状態になっていた、というのが一番の学びでした。公開デモを作り直す前に気づけて、本当によかったです🙏
「画面に出していないから大丈夫」と思っている API があったら、一度 curl で中身をのぞいてみてください。同じような見落としに気づくきっかけになれば嬉しいです🙌
参考
- OWASP API Security Top 10: API3:2023 Broken Object Property Level Authorization
https://api-security.owasp.org/editions/2023/en/0xa3-broken-object-property-level-authorization - OWASP Cheat Sheet Series: Authentication Cheat Sheet(Authentication and Error Messages)
https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#authentication-and-error-messages - Ruby on Rails Guides: Securing Rails Applications(Brute-Forcing Accounts)
https://guides.rubyonrails.org/security.html#brute-forcing-accounts