こんにちは。個人開発しているNext.jsアプリでAPI Routeが増えてきたので、セキュリティ処理を共通化してみた、孤独の道のりでしたが足元を照らしてくれました(超かぐや姫!見ました)
今回まとめたのは、次のような汎用処理です。
- Origin検証
- JSONのContent-Type検証
- リクエストボディの容量制限
- 簡易レート制限
- Cron用シークレットの安全な比較
それぞれのAPIに同じ処理を書くと、微妙な実装差やチェック漏れが発生しがち。なので、共通ユーティリティとして切り出し、Route Handlerから呼び出せるようにしました。
この記事ではNext.jsのApp RouterとTypeScriptを使用します。
今回のファイル構成
src/
├── app/
│ └── api/
│ ├── growth-events/
│ │ └── route.ts
│ └── cron/
│ └── daily-metrics/
│ └── route.ts
└── lib/
└── security.ts
共通処理はsrc/lib/security.tsへまとめます。
Originを検証する
Cookieベースの認証を使っているAPIでは、別サイトから意図しないリクエストを送られる可能性があります。
そこで、ブラウザから状態変更を行うAPIではOriginヘッダーを検証します。
export function isAllowedBrowserOrigin(
request: Request,
extraOrigins: string[] = [],
) {
const origin = request.headers.get('origin');
// 本番環境ではOriginなしのリクエストを許可しない
if (!origin) {
return process.env.NODE_ENV !== 'production';
}
const allowedOrigins = new Set([
'https://example.com',
...extraOrigins,
]);
for (const value of [
process.env.NEXT_PUBLIC_APP_URL,
process.env.NEXTAUTH_URL,
]) {
if (!value) continue;
try {
allowedOrigins.add(new URL(value).origin);
} catch {
// 不正な環境変数は許可リストへ追加しない
}
}
if (process.env.NODE_ENV !== 'production') {
allowedOrigins.add(new URL(request.url).origin);
allowedOrigins.add('http://localhost:3000');
}
return allowedOrigins.has(origin);
}
Route Handlerでは、次のように使えます。
import { NextResponse } from 'next/server';
import { isAllowedBrowserOrigin } from '@/lib/security';
export async function POST(request: Request) {
if (!isAllowedBrowserOrigin(request)) {
return NextResponse.json(
{ ok: false, error: 'Forbidden' },
{ status: 403 },
);
}
// 更新処理
return NextResponse.json({ ok: true });
}
request.urlだけを信用しない
本番環境で、次のように書くのは少し危険です。
const allowed = new URL(request.url).origin === origin;
リバースプロキシやホスティング環境の設定によっては、リクエストURLのホスト部分が外部入力の影響を受ける可能性があります。
本番環境では、環境変数や固定値から作った許可リストと比較した方が安全です。
また、Origin検証は認証や認可の代わりにはなりません。
await requireAdmin();
のようなユーザー権限チェックも、必ず別に行います。
JSONのContent-Typeを検証する
APIがJSONだけを受け取る前提なら、Content-Typeも検証します。
const contentType = request.headers
.get('content-type')
?.split(';', 1)[0]
.trim()
.toLowerCase();
if (contentType !== 'application/json') {
throw new Response(
'application/json is required',
{ status: 415 },
);
}
たとえば、次のContent-Typeは許可されます。
application/json
application/json; charset=utf-8
一方、次のようなリクエストは415 Unsupported Media Typeにします。
text/plain
application/x-www-form-urlencoded
multipart/form-data
APIの入力形式を限定しておくと、予想外のパーサー処理を減らせます。
リクエストボディの容量を制限する
Zodで文字数を制限していても、巨大なJSONを読み込んだあとに検証していたら、メモリやCPUを先に消費してしまいます。
そこで、ストリームを読み込みながら容量を確認します。
const DEFAULT_JSON_LIMIT = 32 * 1024;
export async function readJsonWithLimit(
request: Request,
maxBytes = DEFAULT_JSON_LIMIT,
): Promise<unknown> {
const contentType = request.headers
.get('content-type')
?.split(';', 1)[0]
.trim()
.toLowerCase();
if (contentType !== 'application/json') {
throw new Response(
'application/json is required',
{ status: 415 },
);
}
const declaredSize = Number(
request.headers.get('content-length'),
);
if (
Number.isFinite(declaredSize) &&
declaredSize > maxBytes
) {
throw new Response(
'Request body is too large',
{ status: 413 },
);
}
const reader = request.body?.getReader();
if (!reader) {
return null;
}
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > maxBytes) {
await reader.cancel();
throw new Response(
'Request body is too large',
{ status: 413 },
);
}
chunks.push(value);
}
const body = Buffer.concat(chunks).toString('utf8');
return body ? JSON.parse(body) : null;
}
Content-Lengthだけでは不十分
最初はContent-Lengthだけ確認すればよいかなと思ったのですが、それだけでは不十分でした。
const size = Number(
request.headers.get('content-length'),
);
Content-Lengthがないリクエストや、チャンク転送されるリクエストもあるからです。
そのため、次の2段階で検証しています。
-
Content-Lengthがあれば、読み込み前に確認する - 実際のストリームを読みながら、累計バイト数を確認する
これで、ヘッダーがない場合でも上限を適用できます。
Zodと組み合わせる
容量を確認してJSONへ変換したあと、Zodで内容を検証します。
import { z } from 'zod';
const eventSchema = z.object({
eventType: z.enum([
'PAGE_VIEW',
'BUTTON_CLICK',
]),
source: z.string().trim().max(100).optional(),
});
export async function POST(request: Request) {
try {
const body = await readJsonWithLimit(request);
const input = eventSchema.parse(body);
return Response.json({
ok: true,
eventType: input.eventType,
});
} catch (error) {
if (error instanceof Response) {
return Response.json(
{ ok: false },
{ status: error.status },
);
}
return Response.json(
{ ok: false },
{ status: 400 },
);
}
}
役割を分けると、こんなイメージになります。
| 処理 | 担当 |
|---|---|
| Content-Type検証 | readJsonWithLimit() |
| 最大バイト数の検証 | readJsonWithLimit() |
| JSONへの変換 | readJsonWithLimit() |
| プロパティや文字数の検証 | Zod |
| 認証・認可 | 各APIの認証処理 |
一つの検証だけに頼らず、複数の層で守る形にしています。
簡易レート制限を実装する
公開APIへの大量リクエストを少し抑えるため、固定ウィンドウ方式のレート制限も作りました。
type RateLimitRecord = {
count: number;
resetAt: number;
};
const rateLimitStore = new Map<
string,
RateLimitRecord
>();
export function checkRateLimit(
request: Request,
scope: string,
limit: number,
windowMs = 60_000,
) {
const forwardedFor = request.headers
.get('x-forwarded-for')
?.split(',')[0]
?.trim();
const realIp = request.headers
.get('x-real-ip')
?.trim();
const clientId =
forwardedFor || realIp || 'unknown';
const key = `${scope}:${clientId}`;
const now = Date.now();
const current = rateLimitStore.get(key);
if (!current || current.resetAt <= now) {
rateLimitStore.set(key, {
count: 1,
resetAt: now + windowMs,
});
return {
allowed: true,
retryAfter: 0,
};
}
current.count += 1;
if (rateLimitStore.size > 10_000) {
for (const [storedKey, value] of rateLimitStore) {
if (value.resetAt <= now) {
rateLimitStore.delete(storedKey);
}
}
}
return {
allowed: current.count <= limit,
retryAfter: Math.max(
1,
Math.ceil(
(current.resetAt - now) / 1000,
),
),
};
}
Route Handlerから使ってみます。
export async function POST(request: Request) {
const rateLimit = checkRateLimit(
request,
'growth-events',
120,
);
if (!rateLimit.allowed) {
return Response.json(
{
ok: false,
error: 'Too Many Requests',
},
{
status: 429,
headers: {
'Retry-After': String(
rateLimit.retryAfter,
),
},
},
);
}
return Response.json({ ok: true });
}
scopeを分けることで、APIごとに別のカウンターを使えます。
checkRateLimit(request, 'login', 10);
checkRateLimit(request, 'analytics', 120);
checkRateLimit(request, 'upload', 20);
インメモリのレート制限には限界がある
ここは大事なところです。
今回のMapを使った実装は、単一プロセス内での簡易的な防御にすぎません。
サーバーレスや複数インスタンス構成では、インスタンスごとにカウンターが分かれます。また、プロセスが再起動すると記録も消えます。
本番で厳密な制限が必要なら、次のような共有ストレージを使う必要があります。
- Redis
- Upstash
- Cloudflare KV/Durable Objects
- データベース
- API GatewayやCDN側のWAF
個人開発の初期段階ではインメモリでも一定の効果がありますが、ログイン試行や課金APIなど、重要な処理には分散対応のレート制限を使いたいところです。
X-Forwarded-Forを無条件に信用しない
X-Forwarded-Forは、クライアントが直接指定できる場合があります。
そのため、信頼できるCDNやリバースプロキシを経由する構成にして、プロキシ側でヘッダーを上書きする必要があります。
Client
↓
Trusted CDN / Load Balancer
↓ X-Forwarded-Forを設定
Next.js
プロキシ構成が不明な状態で、X-Forwarded-Forを認証やアクセス許可の根拠にするのは避けます。
今回は、あくまでレート制限の識別子として利用しています。
Cron用シークレットを安全に比較する
Cron APIでは、専用ヘッダーにシークレットを設定します。
X-Cron-Secret: very-long-random-secret
次のような単純比較でも動作はします。
supplied === expected
ただし、秘密値の比較では処理時間の差を減らすため、timingSafeEqual()を使う方法があります。
import {
createHash,
timingSafeEqual,
} from 'node:crypto';
function equalSecret(
left: string,
right: string,
) {
const leftHash = createHash('sha256')
.update(left)
.digest();
const rightHash = createHash('sha256')
.update(right)
.digest();
return timingSafeEqual(
leftHash,
rightHash,
);
}
export function hasValidCronSecret(
request: Request,
) {
const expected = process.env.CRON_SECRET;
const supplied = request.headers.get(
'x-cron-secret',
);
return Boolean(
expected &&
supplied &&
equalSecret(supplied, expected),
);
}
両方をSHA-256でハッシュしてから比較しているのは、timingSafeEqual()が同じ長さのバッファを要求するためです。
シークレットをクエリパラメータに入れない
次のようなURLは避けます。
/api/cron/daily?secret=xxxxxxxx
クエリパラメータは、アクセスログや監視サービス、ブラウザ履歴などへ残る可能性があります。
代わりに、専用ヘッダーで渡します。
curl \
-H "X-Cron-Secret: ${CRON_SECRET}" \
https://example.com/api/cron/daily
Route Handler側は次のようになります。
import { NextResponse } from 'next/server';
import { hasValidCronSecret } from '@/lib/security';
export async function GET(request: Request) {
if (!hasValidCronSecret(request)) {
return NextResponse.json(
{
ok: false,
error: 'unauthorized',
},
{
status: 401,
},
);
}
// Cron処理
return NextResponse.json({ ok: true });
}
もちろん、環境変数の値をログへ出力しないことも大切です。
公開POST APIへまとめて適用する
ここまでの処理を組み合わせると、Route Handlerは次のようになります。
import { NextResponse } from 'next/server';
import { z } from 'zod';
import {
checkRateLimit,
isAllowedBrowserOrigin,
readJsonWithLimit,
} from '@/lib/security';
const schema = z.object({
eventType: z.enum([
'PAGE_VIEW',
'BUTTON_CLICK',
]),
source: z.string().trim().max(100).optional(),
});
export async function POST(request: Request) {
try {
if (!isAllowedBrowserOrigin(request)) {
return NextResponse.json(
{ ok: false },
{ status: 403 },
);
}
const rateLimit = checkRateLimit(
request,
'events',
120,
);
if (!rateLimit.allowed) {
return NextResponse.json(
{ ok: false },
{
status: 429,
headers: {
'Retry-After': String(
rateLimit.retryAfter,
),
},
},
);
}
const body = await readJsonWithLimit(
request,
32 * 1024,
);
const input = schema.parse(body);
// DBへの保存など
console.log(input.eventType);
return NextResponse.json({
ok: true,
});
} catch (error) {
if (error instanceof Response) {
return NextResponse.json(
{ ok: false },
{ status: error.status },
);
}
return NextResponse.json(
{ ok: false },
{ status: 400 },
);
}
}
処理の順番は次のようにしました。
- Origin検証
- レート制限
- Content-Type検証
- ボディ容量制限
- JSONのパース
- Zodによる入力検証
- DBなどの本処理
不正なリクエストを、できるだけ手前で終了させる構成です。
テストを書く
セキュリティ処理は、リファクタリングで消えてしまうと困ります。最低限のテストも用意しました。
import assert from 'node:assert/strict';
import test from 'node:test';
import {
hasValidCronSecret,
isAllowedBrowserOrigin,
readJsonWithLimit,
} from '../src/lib/security';
test('Cronは専用ヘッダーを要求する', () => {
const previous = process.env.CRON_SECRET;
process.env.CRON_SECRET = 'correct-secret';
try {
const validRequest = new Request(
'https://example.com/api/cron/daily',
{
headers: {
'x-cron-secret': 'correct-secret',
},
},
);
assert.equal(
hasValidCronSecret(validRequest),
true,
);
const queryRequest = new Request(
'https://example.com/api/cron/daily' +
'?secret=correct-secret',
);
assert.equal(
hasValidCronSecret(queryRequest),
false,
);
} finally {
if (previous === undefined) {
delete process.env.CRON_SECRET;
} else {
process.env.CRON_SECRET = previous;
}
}
});
test('大きすぎるJSONを拒否する', async () => {
const request = new Request(
'https://example.com/api/events',
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
value: 'too long',
}),
},
);
await assert.rejects(
() => readJsonWithLimit(request, 8),
(error: unknown) => {
return (
error instanceof Response &&
error.status === 413
);
},
);
});
「正常な入力を受け付ける」だけではなく、次の拒否ケースもテストすると安心です。
- Originが異なる
- Originがない
- Content-Typeが違う
- Content-Lengthが上限を超える
- ストリームの実サイズが上限を超える
- Cronシークレットが違う
- シークレットをクエリに入れている
- レート制限を超える
今回の実装で意識したこと
認証と入力検証を混同しない
Originが正しくても、そのユーザーに操作権限があるとは限りません。
if (!isAllowedBrowserOrigin(request)) {
return new Response(null, { status: 403 });
}
await requireAdmin();
Origin検証、認証、認可はそれぞれ別の役割として扱います。
クライアントから受け取る値は信用しない
次のような値は、すべて書き換えられる前提で扱います。
OriginContent-TypeContent-LengthX-Forwarded-For- JSONの各プロパティ
単独の値に依存せず、許可リストや実データの計測、Zodなどを組み合わせました。
エラーで内部情報を返さない
サーバー側では詳細を記録しても、クライアントには必要最低限の情報だけ返します。
console.error('API failed', error);
return NextResponse.json(
{
ok: false,
error: 'Request failed',
},
{
status: 500,
},
);
DB接続文字列やAPIキー、スタックトレースなどはレスポンスへ含めません。
まとめ
今回は、Next.jsのRoute Handlerで使える汎用セキュリティ処理をTypeScriptで共通化しました。
実装した内容はこちらです。
- Originを許可リストで検証する
- JSON以外のContent-Typeを拒否する
- ストリームを読みながら容量を制限する
- 公開APIに簡易レート制限を適用する
- Cronシークレットをヘッダーで受け取る
- シークレットを定数時間比較する
- 拒否ケースも自動テストする
もちろん、これだけでAPIが完全に安全になるわけではありません。認証・認可、依存関係の更新、ログ監視、WAF、データベース設計なども必要です。
でも、各Route Handlerへバラバラに書いていたチェックを共通化すると、APIを追加するときの抜け漏れはかなり減らせました。
セキュリティ処理って少し難しく見えますが、小さな共通関数へ分けていくと意外と整理しやすかったです。私も引き続き改善していこうと思います。