メインコンテンツまでスキップ

ミドルウェアとレート制限 — 横断的な守りを 1 か所に置く

前章までで、認証と認可が入りました。残っているのは、リクエストが個別の処理に届く前に済ませたい判断です。

第2章で追った経路を思い出してください。リクエストはミドルウェアを通ってからルーターに届きます。この章では、その層に自分で処理を挟みます。前章で作ったログイン API を総当たりから守るレート制限も、ここで入れます。

ミドルウェアが挟まる場所

auth:sanctum を使ってきましたが、あれもミドルウェアです。リクエストとレスポンスの両方に触れる位置にあります。

行きと帰りの両方を通ります。 リクエストを検査して弾くこともできますし、レスポンスにヘッダを足すこともできます。

適用する範囲は 3 通りあります。

範囲書き方
全リクエストbootstrap/app.phpappend / prependリクエスト ID の付与
グループ単位api / web グループへの追加CORS
ルート単位->middleware('...')auth:sanctum

ミドルウェアを自作する

決済サービスからの通知を受け取るエンドポイントを作ります。第1章のエンドポイント一覧にあった POST /api/v1/webhooks/payment です。

Webhook は、外部のサービスが自分のサーバーを直接叩いてくる仕組みです。認証トークンを渡せないので、代わりに署名で本物かどうかを確かめます。

送信側は、リクエストボディと共有の秘密鍵から署名を計算し、ヘッダに載せてきます。受け取る側は同じ計算をして、一致するかを見ます。

php artisan make:middleware VerifyPaymentWebhookSignature
app/Http/Middleware/VerifyPaymentWebhookSignature.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyPaymentWebhookSignature
{
public function handle(Request $request, Closure $next): Response
{
$signature = $request->header('X-Payment-Signature');

if (! is_string($signature)) {
abort(401);
}

$expected = hash_hmac(
'sha256',
$request->getContent(),
config('services.payment.webhook_secret'),
);

if (! hash_equals($expected, $signature)) {
abort(401);
}

return $next($request);
}
}

3 点、意図があります。

$request->getContent() で生のボディを取っています。 $request->all() ではありません。署名は送信側が送ったバイト列そのものに対して計算されているので、パース後の配列から復元しても一致しません。JSON のキーの順序や空白の入り方が変われば、別のバイト列になります。

hash_equals() で比較しています。 === でも動きますが、文字列の比較は先頭から順に見て違いが出た時点で終わります。一致する文字数によって処理時間がわずかに変わるので、時間を測りながら 1 文字ずつ試すと署名を推測できます (タイミング攻撃)。hash_equals() は長さが同じなら常に同じ時間で比較します。

秘密鍵を config() から読んでいます。 第2章で決めたとおり、env() はここでは呼びません。この設定は config/services.php に足しておきます。

config/services.php
return [
// ... 既存の設定 ...

'payment' => [
'key' => env('PAYMENT_API_KEY'),
'webhook_secret' => env('PAYMENT_WEBHOOK_SECRET'),
],
];

この追加を忘れると、config()null を返します。 hash_hmac() は空の鍵でも計算を続けるのでエラーにならず、署名の検証が実質的に無効になります。エラーが出ないぶん、気づくのは「なぜか常に 401 になる」または「偽の Webhook が通った」ときです。

登録する

作ったミドルウェアには名前を付けて、ルートから参照できるようにします1

bootstrap/app.php
use App\Http\Middleware\VerifyPaymentWebhookSignature;

->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'webhook.payment' => VerifyPaymentWebhookSignature::class,
]);
})
routes/api.php
Route::post('/webhooks/payment', [PaymentWebhookController::class, 'handle'])
->middleware('webhook.payment');

全リクエストに適用したいものは append を使います。

$middleware->append(AddRequestId::class);

api グループ全体に足すこともできます。

$middleware->api(append: [
EnsureJsonResponse::class,
]);

受け口を書く

ルートが参照している PaymentWebhookController を作ります。

php artisan make:controller PaymentWebhookController
app/Http/Controllers/PaymentWebhookController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PaymentWebhookController extends Controller
{
public function handle(Request $request)
{
// 署名はミドルウェアが検証済み。ここに来た時点で送信元は本物
if ($request->input('event') !== 'payment.succeeded') {
return response()->json(['status' => 'ignored']);
}

// 入金の記録は第17章で組み立てます
return response()->json(['status' => 'accepted']);
}
}

扱わないイベント種別も 200 で返します。 決済サービスは 1 つのエンドポイントに複数種類の通知を送ってきます。知らない種別に 4xx や 5xx を返すと、送信側は失敗と見なして再送を続けます。この章の後半で扱う再送の仕組みが、意図せず動き続けることになります。受け取ったが何もしなかったことは 200 で伝えます。

入金の記録そのものはここに書きません。 同じ通知が 2 回届く前提の設計が要るので、第17章で扱います。

レート制限

前章のログイン API には、回数の制限がありません。パスワードを何度でも試せます。

Laravel のレート制限は 2 段階で設定します。制限の内容を名前付きで定義しルートに適用します

定義は AppServiceProviderboot メソッドに書きます2

app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
}

第11章で書いたログインのルートに、throttle を足します。

routes/api.php
Route::post('/auth/login', [AuthController::class, 'login'])
->middleware('throttle:login'); // ← 追加

これで、同じ IP アドレスから 1 分間に 6 回目のログイン試行をすると 429 Too Many Requests が返ります。

何を基準に数えるか

by() に渡す値が、カウントの単位になります。ここの設計が効きます。

IP アドレスだけで数えると、抜け道があります。攻撃する側が IP を分散させれば制限に掛かりません。1 つのアカウントに対して、100 個の IP から 5 回ずつ試せば 500 回試したことになります。

メールアドレスでも数えます2

RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(500),
Limit::perMinute(5)->by($request->input('email')),
];
});

配列で返すと、すべての制限が同時に効きます

  • 1 つ目は全体の上限。エンドポイント全体で 1 分 500 回まで
  • 2 つ目はアカウント単位。同じメールアドレスへの試行は 1 分 5 回まで

IP を分散されても、狙われたアカウントへの試行は 5 回で止まります。

認証済みユーザーで数える

一般的な API のレート制限では、ログインしていればユーザー ID、していなければ IP を使います。

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

同じ利用者が複数のデバイスから使っても、合計で数えられます。

単位が重なるときは接頭辞を付ける

同じ値で複数の制限を掛けるときは注意が要ります2

✅ Good: 接頭辞で区別する
RateLimiter::for('uploads', function (Request $request) {
return [
Limit::perMinute(10)->by('minute:'.$request->user()->id),
Limit::perDay(1000)->by('day:'.$request->user()->id),
];
});

接頭辞なしで両方 $request->user()->id にすると、2 つの制限が同じカウンタを共有します。分単位の制限と日単位の制限が混ざり、意図した動作になりません。

429 のレスポンス

制限に掛かると、Laravel が自動的に 429 を返します。ヘッダには次の情報が入ります。

X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
Retry-After: 47

Retry-After は「あと何秒待てばよいか」です。クライアントはこれを読んで待つべきで、すぐに再試行を繰り返すと制限が延びるだけです。

API のドキュメントには、この 3 つのヘッダの意味を書いておいてください。書かれていなければ、クライアント側は 429 を受けても何をすべきか分かりません。

CORS

ブラウザで動くフロントエンドが別ドメインにあるなら、CORS の設定が要ります。

CORS の設定ファイルは、初期状態では config/ に置かれていませんcors.phpview.php は既定で publish されない設定ファイルです3。既定の挙動のままでよければ、何もしなくて済みます。

許可するオリジンを絞るには、まず設定ファイルを取り出します3

php artisan config:publish cors

生成された config/cors.php を編集します。

config/cors.php
'allowed_origins' => ['https://shop.example.com'],

'*' のまま本番に出さないでください。 どのサイトからでも、ブラウザ経由でこの API を呼べる状態になります。

なお CORS はブラウザの仕組みなので、サーバー側の認可の代わりにはなりません。curl やモバイルアプリからのリクエストには影響しません。前章で入れた認可が本体で、CORS はブラウザ上の別サイトからの呼び出しを制限するだけです。

テスト

レート制限が効いていることを確認します。

tests/Feature/LoginRateLimitTest.php
test('ログインの試行は5回で制限される', function () {
User::factory()->create(['email' => 'taro.test@example.com']);

foreach (range(1, 5) as $i) {
$this->postJson('/api/v1/auth/login', [
'email' => 'taro.test@example.com',
'password' => 'wrong-password',
])->assertStatus(422);
}

$this->postJson('/api/v1/auth/login', [
'email' => 'taro.test@example.com',
'password' => 'wrong-password',
])->assertStatus(429);
});

5 回目までは認証エラーの 422、6 回目で 429 になります。

テスト間でカウンタが残ります

レート制限のカウンタはキャッシュに保存されます。第10章で触れたとおり、RefreshDatabase はデータベースしか戻しません。前のテストで積んだカウントが次のテストに残ります。

このテストの前後でキャッシュを消してください。

beforeEach(function () {
Cache::clear();
});

キャッシュそのものは第18章で扱います。ここでは「レート制限がキャッシュを使っている」という事実だけ押さえておいてください。

Webhook の署名検証もテストします。異常系だけでは足りません。

tests/Feature/PaymentWebhookTest.php
test('署名が正しいWebhookは受理される', function () {
config(['services.payment.webhook_secret' => 'test-secret']);

$payload = ['event' => 'payment.succeeded', 'order_id' => 1];
$body = json_encode($payload);
$signature = hash_hmac('sha256', $body, 'test-secret');

$this->call(
'POST',
'/api/v1/webhooks/payment',
content: $body,
server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_X_PAYMENT_SIGNATURE' => $signature,
],
)->assertStatus(200);
});

test('署名が不正なWebhookは401になる', function () {
config(['services.payment.webhook_secret' => 'test-secret']);

$this->postJson('/api/v1/webhooks/payment', ['event' => 'payment.succeeded'], [
'X-Payment-Signature' => 'invalid',
])->assertStatus(401);
});

異常系のテストだけを書くと、コントローラが存在しなくても green になります。ミドルウェアが 401 で止めるので、その先に到達しないためです。「署名が正しければ通る」を書いて初めて、経路全体が動いていることを確認できます。

正常系では $this->call() を使っています。postJson() は配列を JSON に変換して送るので、こちらが署名を計算したバイト列と一致する保証がありません。本文と署名の対応を確かめたいテストでは、送るバイト列を自分で決めます。

本番で効く注意点

api グループに throttle は入っていない

Laravel の api ミドルウェアグループには、既定でレート制限が含まれていません。 「Laravel だから何か制限されているはず」と考えないでください。

明示的に付けたルートだけが制限されます。公開しているエンドポイントを一覧にして、それぞれに制限が要るかを判断してください。

ID 列挙には通常のレート制限だけでは足りない

第4章と第12章で、ID を順に試して情報を集める攻撃に触れました。

Limit::perMinute(60) のような制限を掛けても、60 件ずつなら列挙は進みます。1 分に 60 件、1 時間で 3600 件です。正常な利用を妨げない値にすると、列挙も止められません。

レート制限は「短時間に大量」を止める道具で、「長時間かけて少しずつ」には効きません。ID 列挙への対策は、この章の外にあります。

  • 404 を返す — 第12章で扱いました。403 と 404 を使い分けて、存在の有無を漏らさない
  • 推測できない ID を使う — ULID や UUID なら順に試せません
  • エラー率を監視する — 同じ利用者から 404 が連続するのは異常です。第21章のログ設計で扱います

レート制限はこの 3 つを補うものであって、単独の対策ではありません。

Webhook は 5xx を返すと再送される

決済サービスの多くは、Webhook が 5xx を返すと時間をおいて再送します。これは正しい設計で、一時的な障害でイベントを取りこぼさないための仕組みです。

受け取る側は、同じイベントが 2 回来ても正しく動く必要があります。「入金を記録する」処理が 2 回走れば、二重に記録されます。

この対策 (冪等性) は第14章と第17章で扱います。Webhook を受ける実装を書くときは、この前提を忘れないでください。

ミドルウェアの順序に依存しない設計にする

複数のミドルウェアを付けると、実行順が結果を変えることがあります。auth:sanctum の後に置いたミドルウェアは $request->user() を使えますが、前に置けば null です。

順序に依存する処理を書くなら、その依存を明示してください。ミドルウェアのクラスに 1 行コメントを置くだけでも、後から読む人が順序を入れ替えなくなります。

レート制限の値は運用しながら決める

最初から正しい値は分かりません。厳しすぎれば正常な利用者が弾かれ、緩すぎれば意味がありません。

429 の発生数をログに残して、後から調整できるようにしてください。 第21章でログ設計を扱いますが、レート制限は監視の対象として優先度が高い項目です。正常な利用者が弾かれていることに気づけるのは、ログか問い合わせだけです。

まとめ

  • ミドルウェアはリクエストの行きと帰りの両方を通る。全体・グループ・ルートの 3 段階で適用できる
  • Webhook の署名検証は生のボディに対して行う。パース後の配列から復元しても一致しない
  • 署名の比較は hash_equals() を使う。=== はタイミング攻撃の余地を残す
  • レート制限は AppServiceProvider::boot() で定義し、throttle:名前 で適用する
  • IP だけで数えると分散されて抜けられる。ログインはアカウント単位でも数える
  • 同じ値で複数の制限を掛けるときは接頭辞でカウンタを分ける
  • api グループに throttle は入っていない。付けたルートだけが守られる
  • Webhook は 5xx で再送される。同じイベントが 2 回来る前提で作る

次に読む

ここまでで、API を守る層が揃いました。次章から、同時に来るリクエスト時間がかかる処理に取り組みます。次章 トランザクションと同時実行制御 — 在庫の二重引き当てを塞ぐ では、第5章から先送りにしてきた「確認と更新のあいだに別のリクエストが入る」問題を、トランザクションと行ロックで解決します。

練習問題

次の Webhook 検証には、署名が一致しない問題があります。原因を説明してください
public function handle(Request $request, Closure $next): Response
{
$expected = hash_hmac(
'sha256',
json_encode($request->all()),
config('services.payment.webhook_secret'),
);

if ($expected !== $request->header('X-Payment-Signature')) {
abort(401);
}

return $next($request);
}

解答例

json_encode($request->all()) で署名を計算しています。 これは送信側が署名したバイト列とは別物になりえます。

$request->all() は、受け取った JSON をパースして PHP の配列にしたものです。それを json_encode() で文字列に戻しても、元のバイト列と同じになる保証はありません

具体的にずれる要因を挙げます。

  • キーの順序 — パースと再生成の過程で順序が保たれるとは限りません
  • 空白と改行 — 送信側が整形した JSON を送っていれば、json_encode() の出力とは違います
  • 数値の表現1.01 になる、指数表記が変わる、といった差が出ます
  • Unicode のエスケープjson_encode() は既定で日本語を \uXXXX にエスケープします

どれか 1 つでも違えば、ハッシュはまったく別の値になります。「たまに通ってたまに落ちる」ではなく、ほぼ常に落ちます

正しくは $request->getContent() です。送られてきたバイト列そのものを使います。

もう 1 つ、!== での比較も直す価値があります。タイミング攻撃の余地を残さないよう hash_equals() を使います。

「IP アドレスで 1 分 5 回に制限したので、ログインの総当たりは防げた」という判断にどう応じますか

解答例

防げていません。 制限の単位が攻撃の単位と一致していないためです。

攻撃する側から見ると、IP アドレスは容易に変えられます。クラウドのインスタンス、プロキシ、ボットネット。100 個の IP を使えば、1 分に 500 回試せます。制限は「1 IP あたり 5 回」を守っているので、制限としては正しく動いています。守りたかったものが守られていないだけです。

守りたいのは「1 つのアカウントに対する試行」です。だから、その単位で数えます。

RateLimiter::for('login', function (Request $request) {
return [
Limit::perMinute(500),
Limit::perMinute(5)->by($request->input('email')),
];
});

IP を分散されても、狙われたアカウントへの試行は 1 分 5 回で止まります。

ただし、この設定にも副作用があります。攻撃者が特定のアカウントを意図的に締め出せます。誰かのメールアドレスに対して 1 分 5 回の失敗を送り続ければ、本人がログインできなくなります (サービス拒否)。

完全な対策はありませんが、緩和はできます。成功したログインではカウンタをリセットする、一定回数を超えたら本人にメールで通知する、時間帯や地域が普段と違う試行だけ厳しくする。どこまでやるかは、守るものの価値と実装コストの兼ね合いです。

重要なのは、「制限を掛けた」で終わらせず、何の単位で何を守っているかを言語化することです。 単位がずれていれば、制限は動いていても効いていません。


Footnotes

  1. 出典: Middleware(Laravel 公式ドキュメント 13.x)。bootstrap/app.phpwithMiddleware によるミドルウェアの登録、alias によるルート単位の指定、append によるグローバル適用について。

  2. 出典: Routing(Laravel 公式ドキュメント 13.x)。「Rate Limiting」の節。AppServiceProviderboot メソッドで RateLimiter::for を定義すること、配列を返して複数の制限を同時に効かせること、同じ by の値を使うときに接頭辞で区別すること、throttle ミドルウェアでの適用について。 2 3

  3. 出典: Configuration(Laravel 公式ドキュメント 13.x)。cors.phpview.php が既定では publish されないこと、php artisan config:publish cors で取り出せることについて。 2