0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発でサービスをリリースした。Next.js APIの共通セキュリティ処理をTypeScriptで作ってみた。

0
Posted at

こんにちは。個人開発している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段階で検証しています。

  1. Content-Lengthがあれば、読み込み前に確認する
  2. 実際のストリームを読みながら、累計バイト数を確認する

これで、ヘッダーがない場合でも上限を適用できます。

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 },
    );
  }
}

処理の順番は次のようにしました。

  1. Origin検証
  2. レート制限
  3. Content-Type検証
  4. ボディ容量制限
  5. JSONのパース
  6. Zodによる入力検証
  7. 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検証、認証、認可はそれぞれ別の役割として扱います。

クライアントから受け取る値は信用しない

次のような値は、すべて書き換えられる前提で扱います。

  • Origin
  • Content-Type
  • Content-Length
  • X-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を追加するときの抜け漏れはかなり減らせました。

セキュリティ処理って少し難しく見えますが、小さな共通関数へ分けていくと意外と整理しやすかったです。私も引き続き改善していこうと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?