🔐

未ログインでも叩けるAPIエンドポイントにレートリミットを導入する

2023/05/03に公開7

先日だれでもAIメーカーというWebサービスをリリースしました。このサービスは例によってOpenAI APIを使っており、トークンの使用量がランニングコストに大きく影響します。

また、気軽に使ってもらえるよう未ログインでも使用できる仕様にしているため、気をつけないと悪意のある人に大量にトークンを使用されてしまう可能性があります。

ノーガードだとどうなるか

例えば、POST /api/askという「リクエストbodyのpromptの値を取り出し、OpenAI APIのChat Completionsに投げる」という単純なエンドポイントを作ったとします。

「未ログインでも使ってもらいたいから」と認証を一切しなかった場合どうなるでしょうか? 悪意のある攻撃者に見つかれば、promptを上限ギリギリの長さの文章に設定したうえで、/api/askに対してDoS攻撃するかもしれません。

トークンを大量消費するリクエストを連続されてしまえば、OpenAIで設定している利用上限に一瞬で達してしまう可能性があります。

未ログインユーザーへのレートリミットを導入する

IPアドレスで制限する

一般的なやり方の一つはIPアドレスごとにレートリミットをかけることです。今回のプロジェクトではUpstashというサーバーレスでRedisが使えるサービスを使いました。

@upstash/ratelimitというレートリミット用のnpmパッケージを使うととても楽に実装ができます。

// @upstash/ratelimitの使用イメージ

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: new Redis({...}),
  // 10分間に20回までリクエストを受け付ける
  limiter: Ratelimit.slidingWindow(20, "10 m"),
});

const { success } = await ratelimit.limit(req.ip);
if (!success) {
   return res.status(429).json({
      message: 'Too many requests',
   });
}

これだけでも十分かもしれませんが、同じWi-Fiルーターに接続してる友達/家族同士でワイワイ使ってもらうシーンを考えると、IPアドレスごとのレートリミットは緩めに設定したくなったりします。

reCAPTCHAやCloudflare Turnstileを使う

レートリミットではありませんが、reCAPTCHAやCloudflare Turnstileを使ってリクエストが人間によるものであることを確認するのも有効だと思います。

reCAPTCHA v3ではユーザーの追加操作(チェックを入れるやつとか)が不要なので、ユーザー体験をほとんど損なわずに導入できます(ただしレスポンスは少し遅くなる)。

例えば、保護したいエンドポイントにリクエストを送る直前にブラウザでreCAPTCHAのトークンを取得し、それを合わせてサーバーに送ります。

async function onSubmit() {
  const token = await grecaptcha.execute('RECAPTCHA_SITE_KEY', {action: 'submit'});
  await fetch(`/api/ask`, {
    ...
    // reCAPTCHAのtokenを合わせてサーバーに送る
  })
}

サーバーでreCAPTCHAのトークンを検証することで、機械によるリクエストの可能性が高ければOpenAI APIを叩く前にエラーを返すことができます。

100万/月リクエストを超えると辛い問題

サービスがスケールしたときのことを考えるとreCAPTCHAには大きな不安要素があります。それは、reCAPTCHAで1ヶ月に100万回以上の認証をすると、$1〜/1000回の料金がかかるという点です。月に200万回認証すると$1,000以上の料金がかかることになります。

また、競合サービスのCloudflare Turnstileも100万回/月以上利用するにはEnterprise Bot Managementというプランにアップグレードする必要があり、一気に敷居が高くなります(※2023年5月時点)。

そのため、保護したいエンドポイントへのリクエストの度にこれらのサービスで認証するのはややパフォーマンスが悪いかもしれません。

参考)Firebase Authenticationの匿名認証

Firebase Authentication(Identity Platformでないもの)であれば、プロジェクトあたり1億個まで無料で匿名アカウントを作成できるようです。ただし、Cloudflare Workersなどエッジランタイムで認証する場合は一工夫必要かもしれません。

https://zenn.dev/codehex/articles/ca85a1babcc046

自前で匿名認証を行いレートリミットを設定する

リクエストの度にreCAPTCHAを使うのを避けたければ、自前で匿名認証のような機能を用意しても良いかもしれません。

例えば、以下のような流れで簡単な認証が実現できると思います。
※ あくまでも一例(もっと効率的なやり方がある気もする)

  1. (ブラウザ)匿名ログイン用のエンドポイントを叩く ※ここでだけreCAPTCHAを使う
  2. (サーバー)ユニークな匿名ユーザーIDを生成する。そのIDをレスポンスbodyに含めつつ、IDをハッシュ化した文字列(トークン)をCookieに付与する
  3. (ブラウザ)受け取った匿名ユーザーIDをローカルストレージ等に保存しておき、リクエストの際に合わせてサーバーに送る
  4. (サーバー)Cookieからトークンを取得し、匿名ユーザーIDと突き合わせて検証する
    • 不正であれば再度匿名認証を促す
    • 妥当であれば匿名ユーザーIDのレートリミットを確認したうえでOpenAI APIにリクエストを送る

各ステップについてもう少し詳しく説明しておきます。

1. クライアントから匿名ログイン用のエンドポイントへリクエスト

まずクライアントから匿名ログイン用のエンドポイントを叩きます。機械的に行われないように、ここでのみreCAPTCHAを使うようにします。

Example.tsx
async function signInAnonymously() {
  // reCAPTCHAのtokenを取得
  const recaptchaToken = await grecaptcha.execute('RECAPTCHA_SITE_KEY', { action: 'submit' });
  // 匿名ログイン用のエンドポイントを叩く
  const result = await fetch(`/api/sign-in-anonymously`, {
    method: `POST`,
    body: { recaptchaToken },
    ...
  })
}

2. サーバーで匿名ユーザーIDとトークンを生成

サーバーで匿名ログインの処理を行います(流れが分かりやすいように細かなバリデーション等は省略しています)。

/api/sign-in-anonymously.ts
// ① reCAPTCHAのトークンを検証
const recaptchaResult = await verifyRecaptchaToken(req.body.recaptchaToken);
// 検証に失敗したらその時点で終了
if (!recaptchaResult.success) {
  return res.status(400).json({ message: "Invalid ReCAPTCHA token" })
}
// ② 匿名ユーザーID(ユニークな文字列)を生成
// (有効期限を設定したくなったときのために一応タイムスタンプを含めておく)
const anonUserId = `${nanoid()}:${new Date().getTime()}`

// ③ 匿名ユーザーIDをハッシュ化してトークンを生成 (Node.jsなら`crypto.createHmac`が使える)
const hmac = createHmac('sha256', process.env.SOME_SECRET);
hmac.update(anonUserId);
const anonUserToken = hmac.digest('hex');

// ④ トークンをCookieに付与する
// (Cookieじゃなくてもいいが総合的に見て不正を防ぎやすいので)
res.setHeader(
  'set-cookie',
  cookie.serialize('anon-user-token', anonUserToken, {
    httpOnly: true,
    secure: true,
    path: '/',
  })
);
// ⑤ レスポンスで匿名ユーザーIDを返す
return res.status(200).json({ anonUserId })

匿名ユーザーIDに対応するトークンの値は環境変数のシークレットを知らない限り特定するのが難しいため、後述のステップにおいて攻撃者が自前で生成したID/トークンの組み合わせはブロックすることが可能になります。

3. 匿名ユーザーIDをローカルストレージ等に保存しておく

クライアントはサーバーからのレスポンスbodyから匿名ユーザーID(anonUserId)を取り出し、ローカルストレージ等に保存しておきます。保護されたエンドポイントにリクエストするときにはこのIDをレスポンスbodyやheaderに含めるようにします。

4. サーバーで認証する

保護されたエンドポイントでは、レスポンスbody or headerの匿名ユーザーIDとCookieのトークンを検証したうえでID単位のレートリミットを確認します。

/api/ask.ts
// 匿名ユーザーIDが存在しないので終了
if(typeof req.body.anonUserId !== 'string') {
  return res.status(401).json({ message: '...' })
}
// Cookieからトークンを取得
const anonUserToken = req.cookies.get('anon-user-token')
if(typeof anonUserToken !== 'string') {
  return res.status(401).json({ message: '...' })
}
// anonUserIdのハッシュ値がトークンと一致するか確認 (実際にはこのあたりは関数に切り出した方が分かりやすいはず)
const hmac = createHmac('sha256', process.env.SOME_SECRET);
hmac.update(anonUserId);
const currentToken = hmac.digest('hex');
if(anonUserToken !== currentToken) {
  return res.status(401).json({ message: '...' })
}
// IDとIPアドレスの両方でレートリミットを設定
const [limitIp, limitAnonId] = await Promise.all([
  ratelimit.limit(req.ip),
  ratelimit.limit(anonUserId)
]);

if (!limitIp.success || !limitAnonId.success) {
  return res.status(429).json({ message: '...' });
}

// 🎉 ここでようやくOpenAIのAPIを叩く

このようにすれば、レートリミットを回避して機械的にOpenAIのAPIを叩かれるのをある程度は防げるのではないかと思います。
また、匿名ユーザーIDやトークンを使って自身が作成したリソースの更新・削除機能に対応する(サーバーではCookieのトークンで認証する)こともできるかもしれません。

より良い方法をご存知の方はコメントなどで教えていただけると嬉しいです。

Discussion

kosei28kosei28

とても参考になる記事をありがとうございます。

Identity Platformを使用するFirebase Authenticationはオプションのアップグレード版で、高度な機能が必要ないならIdentity Platformを使わないFirebase Authenticationで十分だと思います。
Identity Platoformを使わないなら、電話認証以外は無料で無制限に使えて、匿名アカウントはプロジェクトあたり1億個まで作ることができます。

https://firebase.google.com/docs/auth?hl=ja#identity-platform
https://firebase.google.com/pricing?hl=ja
https://firebase.google.com/docs/auth/limits?hl=ja

catnosecatnose

コメントありがとうございます!Identity Platformを使わないFirebase Authenticationなら1億個まで無料で作れるのですね。

Firebase Authenticationの料金表

料金表のチェックマークの意味をちゃんと解釈できていませんでした。記事を修正しておきます!

はがくん@薬剤師&Flutter/Goエンジニアはがくん@薬剤師&Flutter/Goエンジニア

有用な記事をありがとうございます。勉強になります!

ipアドレスではなく、fingerprintを使う方法はアリだと思いますか?

catnosecatnose

具体的にはfingerprintjsを使うようなイメージでしょうか?試したことがないのですが、ドキュメント等を見る限り偽装がしやすそうな気がするのですがどうなんでしょう。

悪用を防ぐことが目的なのであれば、やや不向きかも?(何か知見があれば教えていただきたいです!)

はがくん@薬剤師&Flutter/Goエンジニアはがくん@薬剤師&Flutter/Goエンジニア

返信ありがとうございます。
おっしゃるとおり、fingerprintjsを使うイメージでした。

ブラウザごとの判別になるので「IPアドレスで制限する」の項で記載されている「同じWi-Fiルーターに接続してる友達/家族同士」での制限が上手くできるかと思ってコメントさせて頂きました。
個人的にはAdblockで容易にブロックされるデメリットさえなければ使える技術かもと考えていました。

判別制度や偽装のことを考えると確かに良い選択とは言えないのかもしれません...

未ログインユーザーのレートリミットについて私も同じような検討をしていた時に選択肢として挙がってきたので、catnoseさんの意見をお聴きしたくてコメントさせて頂きました。(私はサービスのスケールはほぼ見込めないと考えてCAPTCHAにしました。)

catnosecatnose

ありがとうございます。なるほどですね。
自分の理解だと

  • Cookieが使えるならCookieを使うのが楽で十分
  • ネットワーク広告などでサードパーティCookieが制限されている中トラッキングしたいときにfingerprintjsのような技術で代用する

というイメージでした。ただfingerprintjsについてあまり詳しくないのでもう少し調べてみます。コメントありがとうございました!