API・データベース ·

Rate Limiterは何を単位に制限するか — 利用者・IP・対象と429の返し方

  • API設計
  • レート制限
  • Laravel

この記事で扱う問題

Laravel で API を作ると、たいてい次のような制限が入っています。

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

1分に60回、ログインしていれば利用者ごと、していなければ IP アドレスごとに数えます。これを全ルートに付けたままにしておくと、例えば次のことが起こり得ます。

  • ロードバランサの後ろで、$request->ip() がロードバランサのアドレスを返している。全利用者が1つの枠を分け合い、混む時間帯に関係のない利用者まで 429 になる
  • 逆に、X-Forwarded-For を無条件に信じる設定にしていて、ヘッダーを書き換えるだけで制限をすり抜けられる
  • 会社や携帯回線の NAT の後ろでは、多くの利用者が同じ IP を共有する。ログイン前の画面で1人が連打すると、同じ回線の全員が止まる
  • ログインの試行を IP ごとに数えているので、多数の IP から1つのアカウントを狙う総当たりは止まらない
  • CSV の出力のような重い API と、軽い参照の API が同じ枠を使い、重い API が1分に60回まで通る
  • 429 に Retry-After がなく、クライアントがすぐに再送して、429 がさらに増える

どれも、「何から何を守るための制限か」を決めないまま、数える単位を1つにしていることから起きます。

判断

数える単位は、制限の目的から決める

私は、制限を目的ごとに分け、それぞれで単位を決めます。

目的 例 数える単位 判定できないとき(Redis の障害)
資源を守る 一般の API、重い出力・検索 利用者(未認証なら接続元)× API の種類 通す
不正を防ぐ ログイン、パスワード再設定、SMS の送信 狙われる対象(アカウント・電話番号)と接続元の両方 拒否する
契約の上限 外部に公開する API のプラン API キー(契約) 通す

資源を守る制限は、1人の使いすぎで全員が遅くならないためのものです。だから数えるのは「誰が使ったか」で、認証済みなら利用者の ID です。未認証のときだけ、接続元で代用します。

不正を防ぐ制限は、攻撃者が何を変えられるかから考えます。攻撃者は IP を変えられますが、狙うアカウントは変えられません。ログインの総当たりなら、狙われるアカウント(メールアドレス)で数え、1つの接続元から多数のアカウントを試すほうも止めるために、接続元でも数えます。

資源を守る制限:利用者ごとに、API の種類で枠を分ける

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;

final class RateLimitServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // 一般の API。認証済みなら利用者、未認証なら接続元で数える
        RateLimiter::for('api', fn (Request $request): Limit => Limit::perMinute(120)
            ->by($this->subject($request))
            ->response($this->tooManyRequests(...)));

        // 重い API は別の枠にし、短い間隔と長い間隔の両方に上限を置く。
        // 1つの名前の中で同じキーを使うと数が混ざるので、先頭に間隔を付けて分ける
        RateLimiter::for('heavy', fn (Request $request): array => [
            Limit::perMinute(3)->by('minute:' . $this->subject($request))->response($this->tooManyRequests(...)),
            Limit::perHour(20)->by('hour:' . $this->subject($request))->response($this->tooManyRequests(...)),
        ]);
    }

    private function subject(Request $request): string
    {
        $userId = $request->user()?->getAuthIdentifier();

        return $userId !== null ? "user:{$userId}" : 'client:' . ClientKey::fromIp($request->ip());
    }

    /** @param array<string, int|string> $headers Retry-After などを Laravel が組み立てて渡してくる */
    private function tooManyRequests(Request $request, array $headers): JsonResponse
    {
        return new JsonResponse(
            ['message' => 'リクエストが多すぎます。しばらく待ってからやり直してください。'],
            429,
            $headers,
        );
    }
}

ルートには throttle:api と throttle:heavy を付け分けます。重い API に両方付ければ、一般の枠と重い枠の両方で数えられます。利用者で数えるには、制限のミドルウェアが認証のミドルウェアより後に動く必要があります(Laravel の既定のミドルウェアの優先順位では、そうなります)。

API の種類は、エンドポイントごとではなく、「普通」と「重い」程度の少ない区分にします。区分ごとに上限の値を決めて保守するのは人なので、区分が増えるほど値の根拠が曖昧になります。

接続元は、信頼できるアドレスだけで決める

未認証のときに使う IP は、まずロードバランサの後ろで正しく取れている必要があります。

// bootstrap/app.php の withMiddleware() の中
// 自分たちのロードバランサのアドレスだけを信頼する。'*' にすると、直接届いたリクエストの X-Forwarded-For まで信じる
$middleware->trustProxies(at: ['10.0.0.0/16']);

クラウドのロードバランサのようにアドレスが決まらず '*' にするなら、アプリにはロードバランサを通らずに届かない、というネットワークの前提を必ず作ります。Request ID の記事で、受け取った ID は自分たちの上流が付け直したものだけ信頼する、としたのと同じ考え方です。

そのうえで、IPv6 は1つのアドレスではなく、先頭の /64 の範囲で数えます。利用者には /64 以上の範囲がまとめて割り当てられることが多く、アドレスごとに数えると、範囲の中でアドレスを変えるだけで制限を抜けられるからです。

final class ClientKey
{
    /** IPv4 はアドレスそのもの、IPv6 は先頭の /64 を、制限の単位にする */
    public static function fromIp(?string $ip): string
    {
        if ($ip === null) {
            return 'unknown';
        }

        if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
            $packed = (string) inet_pton($ip);

            // ::ffff:203.0.113.5 のような IPv4 射影アドレスは、先頭の 8 バイトがすべて 0 になる。IPv4 に戻して数える
            if (str_starts_with($packed, str_repeat("\0", 10) . "\xff\xff")) {
                return 'v4:' . inet_ntop(substr($packed, 12));
            }

            return 'v6:' . bin2hex(substr($packed, 0, 8));
        }

        return 'v4:' . $ip;
    }
}

不正を防ぐ制限:失敗だけを、対象と接続元の両方で数える

ログインは、ルートの throttle ではなく、ログインの処理の中で数えます。throttle はすべてのリクエストを数えるので、正しいパスワードで何度もログインし直す利用者まで止めてしまいます。数えたいのは失敗だけです。

use Illuminate\Cache\RateLimiter;

final class LoginThrottle
{
    private const DECAY_SECONDS = 900;
    private const MAX_FAILURES_PER_ACCOUNT = 10;
    private const MAX_FAILURES_PER_CLIENT = 100;

    public function __construct(private RateLimiter $limiter)
    {
    }

    /** 制限中なら、あと何秒待てばよいかを返す。制限されていなければ null */
    public function secondsUntilAllowed(string $email, string $clientKey): ?int
    {
        foreach ($this->limits($email, $clientKey) as $key => $maxFailures) {
            if ($this->limiter->tooManyAttempts($key, $maxFailures)) {
                return max(1, $this->limiter->availableIn($key));
            }
        }

        return null;
    }

    public function recordFailure(string $email, string $clientKey): void
    {
        foreach (array_keys($this->limits($email, $clientKey)) as $key) {
            $this->limiter->hit($key, self::DECAY_SECONDS);
        }
    }

    public function recordSuccess(string $email): void
    {
        // 消すのはアカウントの回数だけ。接続元の回数を消すと、自分のアカウントで成功するたびに総当たりを続けられる
        $this->limiter->clear($this->accountKey($email));
    }

    /** @return array<string, int> */
    private function limits(string $email, string $clientKey): array
    {
        return [
            $this->accountKey($email) => self::MAX_FAILURES_PER_ACCOUNT,
            "login:client:{$clientKey}" => self::MAX_FAILURES_PER_CLIENT,
        ];
    }

    private function accountKey(string $email): string
    {
        // メールアドレスをそのまま Redis のキーに残さない
        return 'login:account:' . hash('sha256', mb_strtolower(trim($email)));
    }
}
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;

final class LoginController
{
    public function __construct(
        private StatefulGuard $auth,
        private LoginThrottle $throttle,
    ) {
    }

    public function __invoke(LoginRequest $request): Response|JsonResponse
    {
        $email = $request->validated('email');
        $client = ClientKey::fromIp($request->ip());

        $wait = $this->throttle->secondsUntilAllowed($email, $client);

        if ($wait !== null) {
            return new JsonResponse(
                ['message' => 'ログインの失敗が続いたため、しばらくログインできません。'],
                429,
                ['Retry-After' => (string) $wait],
            );
        }

        if (! $this->auth->attempt(['email' => $email, 'password' => $request->validated('password')])) {
            $this->throttle->recordFailure($email, $client);

            throw ValidationException::withMessages(['email' => 'メールアドレスかパスワードが違います。']);
        }

        $this->throttle->recordSuccess($email);

        return response()->noContent();   // セッションの再生成などは省略
    }
}

(LoginRequest は email と password の形式を確かめるだけの FormRequest です。StatefulGuard には、サービスプロバイダで web のガードを束縛する前提です。)

アカウントの回数は、登録されていないメールアドレスでも同じように数え、同じ応答を返します。登録済みのときだけ制限すると、429 になるかどうかで、そのアドレスが登録済みかを調べられてしまいます。

確かめてから数えるまでの間は1つの操作ではないので、同時に来た試行は、上限を数回分超えて通ることがあります。それでも、15分に10回程度に抑えられれば、総当たりは現実的でなくなります。正確に上限で止める必要はない制限なので、ここではこの程度の誤差を許容しています。

429 には、待つ時間を付けて返す

throttle のミドルウェアは、429 に Retry-After と X-RateLimit-Reset を、通したレスポンスに X-RateLimit-Limit と X-RateLimit-Remaining を付けます。上の tooManyRequests() で本文の形を変えても、$headers をそのまま渡せばヘッダーは残ります。自分で 429 を返すログインでも、Retry-After は必ず付けます。

クライアントの側で Retry-After を読んで待つ作りは、外部 API のタイムアウトとリトライの記事で、呼び出す側の立場から書いたとおりです。自分の API を使うクライアントにも同じ振る舞いを期待するなら、待つ時間を返すのは API の側の責任です。

Redis が使えないとき:目的ごとに、通すか拒否するかを決める

複数のサーバーでアプリを動かすなら、回数はサーバーをまたいで共有するストア(Redis など)に置きます。サーバーごとのメモリやファイルに置くと、上限はサーバーの台数倍になります。Laravel の制限が使うストアは、config/cache.php に 'limiter' => 'ストア名' を書けば、既定のキャッシュとは別に指定できます。

その Redis が落ちると、throttle のミドルウェアは例外を投げ、リクエストは 500 になります。キャッシュの障害の記事の表で「制限の目的で決める」とした部分です。

  • 不正を防ぐ制限は、拒否のままにします。ログインや SMS の送信を制限なしで通すと、障害の間に総当たりや送信の濫用を許します。Laravel の既定の振る舞い(例外で 500)のままで、結果として拒否になります
  • 資源を守る制限は、通します。制限のための Redis が落ちたことで、API 全体を止める理由はありません

通す側は、throttle を包むミドルウェアで決めます。

use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Psr\Log\LoggerInterface;
use RedisClusterException;
use RedisException;
use Symfony\Component\HttpFoundation\Response;

final class ThrottleUnlessUnavailable
{
    public function __construct(
        private ThrottleRequests $throttle,
        private LoggerInterface $logger,
    ) {
    }

    public function handle(Request $request, Closure $next, string $limiter): Response
    {
        $passed = false;

        try {
            return $this->throttle->handle($request, function (Request $request) use ($next, &$passed): Response {
                $passed = true;

                return $next($request);
            }, $limiter);
        } catch (RedisException | RedisClusterException $e) {   // predis なら Predis\Connection\ConnectionException
            if ($passed) {
                // 本体はもう動いた(応答に残りの回数を付ける段階で失敗した)。$next をもう一度呼ぶと二重に実行される
                throw $e;
            }

            $this->logger->warning('rate limiter unavailable', ['limiter' => $limiter, 'exception' => $e]);

            return $next($request);
        }
    }
}

bootstrap/app.php で $middleware->alias(['throttle.soft' => ThrottleUnlessUnavailable::class]) として登録し、資源を守る制限のルートには throttle:api の代わりに throttle.soft:api を、認証のミドルウェアの後に並べて付けます(api のグループに throttleApi() で throttle:api を付けている場合は外します)。

例外を捕まえて $next を呼ぶところで、本体がすでに動いたかを確かめているのは、本体を2回実行しないためです。Redis が本体の実行中に落ちると、そのリクエストは 500 になりますが、更新を2回行うよりはましです。

カウンターを置く Redis のメモリが上限に達したときの設定にも気をつけます。キャッシュと同じインスタンスで古いキーから消す設定(allkeys-lru など)にしていると、カウンターが黙って消え、制限が効かなくなります。不正を防ぐ制限のカウンターは、キャッシュとは別の、キーを消さない設定のインスタンスに置きます。

なぜそう判断したか

IP だけで数えない理由

IP は、1人を表す値としては粗すぎ、細かすぎます。NAT の後ろでは多くの利用者が1つの IP を共有し、IPv6 では1人が多数のアドレスを持ちます。攻撃する側は、IP を変えるのが一番簡単です。IP は、利用者も対象も分からないときの代わりとして使い、分かるならそちらで数えます。

利用者だけで数えない理由

ログイン、会員登録、パスワード再設定のように、守りたい API ほど、利用者がまだ分かりません。そこで対象(アカウント・電話番号)で数えます。ただし対象だけで数えると、1つの接続元から多数のアカウントを1回ずつ試す攻撃は止まらないので、接続元の回数と組み合わせます。

対象で数えることには、代償もあります。攻撃者がわざと失敗を重ねれば、本人がしばらくログインできなくなります。私は、制限を永久のロックにせず、時間がたてば解ける形にして、この代償を小さくしています。それでも困る場合は、制限より先に、二要素認証のように、パスワードを当てられても入られない仕組みを考えるべきだと考えています。

ロードバランサや WAF だけに任せない理由

Nginx の limit_req や WAF は、アプリに届く前に大量のリクエストを止められます。ただ、知っているのは主に IP とパスで、利用者や狙われたアカウントは知りません。IP で粗く止めるのは手前の層、利用者や対象で止めるのはアプリ、と分けて両方使います。

ルートの throttle でログインを数えない理由

throttle は「リクエストが来た」ことを数えます。ログインで防ぎたいのは「パスワードを外し続けること」で、成功したログインまで数えると、正しく使っている利用者が先に止まります。何を数えるかも、単位と同じく目的から決めます。

どこから設計過剰になるか

  • 社内向けの API や管理画面:利用者が限られ、悪意を考えなくてよいなら、既定の throttle:api で足ります。ただし、ロードバランサの後ろで IP が正しく取れているかだけは確かめます
  • アルゴリズムを作り込む:Laravel の制限は、最初のリクエストから一定の時間の窓で数えるので、窓の切れ目の前後で、上限の2倍近くが短い間に通ることがあります。これが問題になるのは、資源を秒単位で守る必要がある場合です。まずは、上の heavy のように短い間隔と長い間隔の上限を組み合わせ、トークンバケットのような方式を自分で実装するのは、それでも足りないと分かってからにします
  • エンドポイントごとに上限を決める:区分を増やすほど、値の根拠を説明できなくなります。「普通」と「重い」で始め、ログで実際に詰まっている API が分かってから区分を足します
  • 月ごとの利用量の上限(クォータ)を Redis の回数で数える:外部に公開する API で、プランごとの月間の上限が請求に関わるなら、消えることがある Redis のカウンターではなく、利用の記録を DB に残して数えます。短い時間の流量を抑える制限と、契約の利用量を数える仕組みは別のものとして作ります。分散した環境で正確に数える仕組みは、私自身まだ深めている途中の領域です

まとめ

  • 数える単位は制限の目的から決める。資源を守るなら利用者(未認証なら接続元)、不正を防ぐなら狙われる対象と接続元の両方、契約なら API キー
  • 接続元の IP は信頼できるプロキシの設定のうえで取り、IPv6 は /64 で数える。ログインは失敗だけを数え、429 には Retry-After を付ける
  • カウンターはサーバーをまたいで共有するストアに置き、そのストアが使えないときは、資源を守る制限は通し、不正を防ぐ制限は拒否する

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。