Ana içeriğe geç
友田 陽大
Generative AI, LLMs & RAG
Next.js
Vercel
AI
セキュリティ
アーキテクチャ設計
コスト最適化

ログイン不要の生成AIチャットを『請求書破産』から守る — エッジ多層防御と、一貫性モデルで選ぶデータストア

ログイン不要の生成AIチャットは、攻撃者にとって『あなたの財布で叩けるLLM API』です。Vercel WAF・Proxy(旧middleware)・Turnstileの4層防御を実コードで設計し、レート制限に結果整合性、決済に強い一貫性を選ぶ理由を、Redis・CockroachDB・DynamoDBのアーキテクチャから解説します。

Published
Reading time
46 min read
Author
友田 陽大
Paylaş
İçindekiler

最初に結論を述べます。ログイン不要の生成AIチャットにおける最大のリスクは「サービスが落ちること」ではなく「請求書が青天井になること」です。 攻撃者はあなたのサーバーを落とす必要がありません。ログインの壁がない推論エンドポイントを、スクリプトで叩き続けるだけでいい。1リクエストが数円でも、毎秒100発を一晩続ければ朝には数十万円です。可用性は無傷のまま、財務だけが壊れます。

そして2026年8月現在、この問題を扱う日本語記事の多くが3つの前提を間違えています

  1. Vercel KV はもう存在しません。 製品として提供終了し、既存ストアは2024年12月に Upstash Redis へ自動移行されました。今から作るなら Marketplace 経由の Upstash Redis です。
  2. middleware.ts は Next.js 16 で非推奨になりました。 proxy.ts へ改名され、既定ランタイムは Edge ではなく Node.js です。しかも公式は「最後の手段として使うことを推奨」と明記しています。
  3. 「エッジでDDoSを面で防ぐ」の主語はあなたのコードではありません。 ボリューム型を自前の proxy で受けると、落としたリクエストの分だけ proxy の実行料金を払うことになります。面の防御は Vercel WAF のレート制限(=関数を起動せずに落とす層)の仕事です。

本記事は、この3点を踏まえた2026年版の正しい部品表で、ログイン不要チャットの多層防御を設計します。前半は防御アーキテクチャ(WAF / Proxy / Route Handler / プロバイダ予算の4層)とその実装、後半は「なぜレート制限は結果整合性でよく、課金は強い一貫性が要るのか」を Redis・CockroachDB・DynamoDB のアーキテクチャから解きます。

なお、レート制限そのもの(固定ウィンドウ vs スライディングウィンドウ、アトミック性、x-forwarded-for の罠)の基礎はNext.jsで『正しく効く』レート制限で扱っています。本記事はその上で、匿名・高単価・LLM という条件が加わったときに何が変わるかに集中します。


1. 脅威モデル:これは「DoS」ではなく「コストのDoS」

TL;DR: 従量課金のクラウドとトークン課金のLLMが組み合わさると、攻撃の目的が「落とす」から「使わせる」に変わります。可用性の指標(エラー率・レイテンシ)は正常なまま被害が進むため、監視の設計も変わります。

OWASP は生成AIアプリ向けの Top 10 で、この脅威を LLM10:2025 Unbounded Consumption(無制限の消費) として独立項目に格上げしました。従来の API セキュリティでは API4:2023 Unrestricted Resource Consumption に相当します。両者に共通する指摘は明快です——リクエストの処理には帯域・CPU・メモリだけでなく、サードパーティAPIの従量課金という金銭的コストがかかる。その消費に上限がなければ、可用性とコストの両方が攻撃面になる。

ログイン不要のチャットに固有の事情を並べると、危険度がはっきりします。

条件通常のAPIログイン不要のLLMチャット
1リクエストの原価0.001円未満(DB読み取り)数円〜数十円(入力+出力トークン)
攻撃に必要な準備認証情報の窃取・量産なし(URLを知っているだけ)
応答時間数十ms数秒〜数十秒(=関数の実行時間も課金)
攻撃者の利得データ窃取無料のLLM代理利用(モデル蒸留・転売)
検知のしやすさエラー率が上がる何も壊れない(請求書だけが増える)

最後の行が本質です。攻撃者にとって「あなたのチャット」は無料で使える LLM プロキシであり、彼らはむしろサービスを健全に保ちたい。だから CPU 使用率もエラー率もレイテンシも正常なまま、月末に請求書が届きます。ダッシュボードの緑は、この攻撃に対して何の保証にもなりません。

もう一つ、匿名チャット特有の派生リスクがあります。攻撃者が高品質なモデルの出力を大量収集して自前モデルの学習に使うモデル蒸留(model distillation)です。これは1回あたりの単価より「総トークン量」で殴られるため、リクエスト数ベースの制限だけでは取りこぼします。後述するトークンバケットの rate オプションで重み付き課金を実装する理由がここにあります。

私自身、無人キオスクでの生成AI音声接客システムを設計・構築した際、最初に設計したのは RAG でもプロンプトでもなく「来店客が誰であれ、1端末・1セッションあたり何回までモデルを呼ぶか」という消費の境界でした。無人・ログイン不要の生成AIを本番に出すというのは、技術的にはモデルを繋ぐ話ではなく、消費の上限をどこに置くかを決める話です。


2. 2026年の正しい部品表 — 変わった3つの前提

TL;DR: Vercel KV は廃止、middleware.tsproxy.ts に改名・Node.js既定、面のDDoSはWAFの領域。この3点を踏まえないと、動かないコードか、無駄に高いコードになります。

2.1 Vercel KV は提供終了 → Marketplace の Upstash Redis

多くの記事がいまだに @vercel/kvkv.get() を書いていますが、Vercel KV は製品として提供されていません。既存の KV ストアは2024年12月に Upstash Redis へ自動移行され、現在の導線は Vercel Marketplace から Upstash for Redis を導入する形です。統合を入れると、認証情報がプロジェクトの環境変数(UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN)に自動注入されます。

コード側の対応は単純です。@vercel/kv を捨て、@upstash/redis@upstash/ratelimit を直接使ってください。歴史的経緯として @vercel/kv は元々 Upstash のラッパーだったので、移行は事実上「ラッパーを剥がす」作業です。

2.2 middleware.ts は非推奨 → proxy.ts(既定は Node.js ランタイム)

Next.js 16 で middleware のファイル規約は proxy に改名され、middleware.ts は非推奨(将来削除予定) になりました。関数名も middleware から proxy に変わります。公式のコドモッドが用意されています。

npx @next/codemod@canary middleware-to-proxy .

同時に、実行モデルも変わりました。バージョン履歴には v16.0.0: Middleware is deprecated and renamed to Proxy. Proxy defaults to the Node.js runtime と明記され、runtime の設定オプションは Proxy では利用不可(設定するとエラー) です。「エッジで動かすために runtime: 'edge' を書く」という古い作法は、Proxy では文字通り例外になります。

さらに重要なのが、Next.js 公式が Proxy の位置づけについて書いていることです。

we are moving away from Middleware ... this feature is recommended to be used as a last resort (Middleware から離れつつある。この機能は最後の手段として使うことを推奨する)

そして設計上の注意として、こうも書かれています。

you should not attempt relying on shared modules or globals (共有モジュールやグローバル変数に依存しようとしてはならない)

これは後述する ephemeralCache(モジュールスコープの Map)の扱いに直結します。あれは正しさの機構ではなく、あくまで最適化である、という理解が必須です。

なお、Vercel 側の製品名は依然 Routing Middleware で、これは Fluid Compute の上に構築され、キャッシュより前に、グローバルに実行されるという性質は変わっていません。「Next.js のファイル規約が proxy.ts に変わった/Vercel のプラットフォーム機能は Routing Middleware のまま」と整理してください。

2.3 面のDDoS防御は「あなたのコード」ではなく WAF の仕事

ここが設計上いちばん誤解されている点です。proxy でレート制限して 429 を返しても、その proxy の実行自体は課金対象です。Routing Middleware は Fluid Compute の課金モデルで、使ったコンピュート資源に応じて課金されます。つまり秒間1万発のフラッドを自前コードで受けると、1万回分の実行料を払いながら防いでいることになります。

Vercel WAF のレート制限は、リクエストが関数に到達する前に落とせます。仕様を正確に押さえておきましょう。

項目HobbyProEnterprise
カウントキーIP、JA4 ダイジェストIP、JA4 ダイジェスト+ User Agent、任意ヘッダ
アルゴリズム固定ウィンドウ固定ウィンドウ+ トークンバケット
ウィンドウ10秒〜10分10秒〜10分10秒〜1時間
ルール数1/プロジェクト40/プロジェクト1000/プロジェクト
アクションLog / Deny / Challenge / 既定の429同左同左

そして公式が明記している重要な制限——レート制限のカウンタはリージョンごとに保持されるため、複数リージョンにまたがるトラフィックは、設定した上限を合計で超えうる。WAF は粗い網であり、精密な上限ではありません。 だからこそアプリ層の制限と併用します。

以上を統合すると、防御は「1枚の壁」ではなく役割の違う4層になります。

何を止めるか実装誤爆時のコスト課金
L1 プラットフォームボリューム型フラッド、既知の悪性IPVercel 自動DDoS緩和 + WAF レート制限高(正規ユーザーごと落ちる)関数を起動しない
L2 Proxy(旧middleware)明らかな濫用、粗いIP単位の上限、本文を読む前の早期棄却proxy.ts@upstash/ratelimitProxy の実行分
L3 Route Handlerセッション単位の精密な上限、ボット判定、入力検証app/api/chat/route.ts + Turnstile低(丁寧なUIで再試行可能)関数の実行分
L4 プロバイダ全体の上限(最後の砦)AI Gateway / プロバイダ側の予算・使用量上限

L4 を必ず入れてください。 L1〜L3 はすべて「あなたのコードが正しく動いていれば」効く防御です。デプロイ事故で proxy の matcher が外れた、環境変数が消えた、Redis が落ちてフェイルオープンした——そのときに請求を止められるのは、プロバイダ側のハード上限だけです。コードの外に、コードでは越えられない天井を置く。 これは冗長ではなく、独立した故障ドメインを作るという設計です。


3. エッジで捌く意味と、捌けないもの

TL;DR: エッジ(キャッシュ前・グローバル実行)の価値は「攻撃をユーザーに近い場所で終わらせ、オリジンとDBに触れさせない」ことです。ただし低遅延の恩恵は、参照するデータストアが遠いと消し飛びます。

中央集権型のアーキテクチャでは、東京の攻撃者からのリクエストも、バージニアの API サーバーまで到達してから拒否されます。往復のレイテンシ、帯域、そして拒否判定を下す計算資源のすべてを、攻撃者が指定した場所ではなくあなたが指定した1点で負担することになる。単一の集中点は、そのままボトルネックであり単一障害点です。

エッジ展開は、この構造を反転させます。Routing Middleware は世界中のPOPでキャッシュより前に実行されるため、拒否判定は攻撃者の隣で終わり、オリジンにも DB にも到達しません。防御が「点」から「面」になる、というのはこの意味においてです。

ただし、ここにエッジ最大の落とし穴があります。Vercel 公式が明記しているとおりです。

If your Routing Middleware depends on a database far away from one of our supported regions, the overall latency of API requests could be slower than expected (Routing Middleware が遠方のデータベースに依存していると、API リクエスト全体のレイテンシは期待より遅くなりうる)

シンガポールのPOPで判定しても、参照する Redis が us-east-1 にあれば、判定のたびに太平洋を往復します。エッジに置いたのに遅くなる、という典型的な失敗です。対策は3つ。

  1. グローバルに読める(あるいは複製された)ストアを選ぶ — Upstash の読み取りレプリカ、Edge Config など。
  2. 判定に必要なデータをリクエスト自身に載せる — 署名済みCookie(後述のセッションID)は、ネットワークを1往復も使わずに検証できます。
  3. ephemeralCache でホットな拒否をローカルに閉じる — すでに上限超過と判明した識別子は、Redis に問い合わせずローカルの Map で即座に落とせます。

3番目について、Upstash のドキュメントは ephemeralCache を「関数がホットな間データをキャッシュし、コールドのときだけ Redis から取得する」機構と説明し、これが効いたときのレスポンスは reason: "cacheBlock" になると定めています。ただし前述のとおり Next.js は Proxy でグローバル依存を避けよと言っています。両者は矛盾しません。 ephemeralCache が消えても正しさは壊れず(Redis に問い合わせ直すだけ)、残っていればコストが下がる——という片方向の最適化だからです。この非対称性を理解せずに「ローカルキャッシュがあるから大丈夫」と考えると壊れます。


4. なぜエッジのKVSは「HTTPベース」でなければならないか

TL;DR: Redis のネイティブプロトコルは TCP 接続ベースで、接続を張りっぱなしにする前提です。使い捨ての関数が何千個も同時に立ち上がるサーバーレス/エッジでは、この前提が接続枯渇として牙を剥きます。Upstash が REST API を提供している理由がこれです。

従来型のサーバーは、起動時にコネクションプールを1つ作り、プロセスが生きている限り再利用します。100台のサーバーが各10接続を持てば1000接続。管理可能です。

サーバーレスは違います。関数インスタンスは使い捨てで、同時に何百・何千と立ち上がり、それぞれが独立したプロセスです。プールの共有相手がいない。しかもエッジランタイム(Cloudflare Workers、WebAssembly、Fastly Compute@Edge など)では、そもそも生の TCP ソケットが使えません。Upstash のドキュメントはこの制約を端的にこう書いています——Redis プロトコルは接続ベース(connection based)だが、REST API はリクエストベース(request based)である、と。

観点TCP接続型 RedisHTTPベース KVS(Upstash REST)
接続モデル長時間保持・プール前提ステートレス(リクエスト単位)
エッジランタイム動かない(TCP不可)動く(fetch のみ)
同時実行1万接続枯渇・ERR max number of clients問題なし(HTTPの多重化)
コールドスタートハンドシェイク+認証のRTTHTTPリクエスト1往復
認証AUTH コマンドBearer トークン
適性常駐サーバー、高頻度パイプラインサーバーレス、エッジ、Function

@upstash/redis は内部的に fetch を使うため、Node.js / Edge / Workers のどれでも同じコードが動きます。これは「便利」以上の意味を持ちます。レート制限のロジックを proxy と Route Handler の両方で共有でき、ランタイムごとに実装を分岐させる必要がない——DRY が守れるということです。


5. レート制限の設計 — 複合キーの罠と、2本のリミッタ

TL;DR: 「IP+セッションID」を1本の文字列キーに連結する設計は穴になります。攻撃者はセッションIDを回すだけでバケツを無限に増やせるからです。粗いIP系リミッタと細かいセッション系リミッタを独立に持ち、両方を通過した場合のみ許可してください。

5.1 なぜ ip:sessionId の1本キーが破綻するのか

素朴な発想はこうです。「IPだけだと同じオフィスの全員が巻き込まれる。セッションIDだけだとCookieを消されたら終わり。じゃあ両方を組み合わせよう」。そして ratelimit.limit(ip + ":" + sessionId) と書く。

これはセキュリティ的にほぼ無意味です。レート制限のキーとは「バケツの識別子」であり、キーが変われば新しいバケツが作られます。攻撃者は Cookie を送らない(あるいは毎回ランダムなIDを送る)だけで、リクエストごとに新品のバケツを手に入れます。IP を含めたことは何の制約にもなりません。

正しい設計は、独立した2本のリミッタを AND で通すことです。

リミッタ識別子上限(例)何を守るか誤爆リスク
粗い網IPv4 は完全なアドレス、IPv6 は /64 プレフィックス60回/分単一発信元からの物量攻撃中(NAT・大学・企業)
細かい網署名済みCookieのセッションID10回/分(バースト20)正常な1利用者の体感を守る低(自分のセッションだけ)

粗い網は「攻撃者が Cookie を捨てても効く」ため防御の背骨、細かい網は「同じIPの他人を巻き込まない」ための公平性の装置です。役割が違うので、上限も別々に設定します。

IPv6 を /64 で丸めるのは実務上の必須事項です。IPv6 では末端ユーザーにすら /64(18,446,744,073,709,551,616 アドレス)が割り当てられるのが普通で、完全アドレスでキーを作ると攻撃者はアドレスを1つずつずらすだけで制限を回避できます。これは IPv4 の常識をそのまま持ち込むと確実に踏む地雷です。

5.2 クライアントIPの取得 — Vercel では信頼できる

一般論として x-forwarded-for はクライアントが自由に詐称できるため、信頼できるプロキシ経由でのみ読むべきヘッダです。ただし Vercel 上では話が違います。公式ドキュメントが明記しています。

we currently overwrite the X-Forwarded-For header and do not forward external IPs. This restriction is in place to prevent IP spoofing. (X-Forwarded-For ヘッダを上書きし、外部IPは転送しない。IPスプーフィング防止のための制限である)

つまり Vercel にデプロイしている限り、x-forwarded-for(および同値の x-real-ip / x-vercel-forwarded-for)はプラットフォームが保証する値です。自前で「左からn番目を取る」といったパース処理を書く必要はありません。ただし Vercel の前段に自前プロキシを挟む構成では x-forwarded-for が上書きされうるため、その場合は x-vercel-forwarded-for を読みます。

5.3 なぜトークンバケットか

@upstash/ratelimit は3つのアルゴリズムを提供しています。LLMチャットに適するのはどれか。

アルゴリズム挙動バースト耐性コストチャット適性
fixedWindow(limit, window)ウィンドウ単位でカウント境界で2倍通る最安△(境界問題)
slidingWindow(limit, window)直前ウィンドウで加重平均滑らか
tokenBucket(refillRate, interval, maxTokens)一定速度で補充、残量があれば通す明示的に設計可能高(計算量)

チャットの利用パターンは「しばらく黙って読む→立て続けに3〜4回質問する」というバースト性を本質的に持ちます。固定ウィンドウで「毎分10回」に絞ると、正常な連投が弾かれてUXが壊れる。トークンバケットなら maxTokens でバーストの許容量、refillRate で長期的な平均レートを独立に設計できます。

// 60秒で10トークン補充、バケツの最大は20
// → 平均10回/分だが、休んだ直後は20回まで連打できる
Ratelimit.tokenBucket(10, "60 s", 20)

引数の順序に注意してください。tokenBucket(refillRate, interval, maxTokens) です(limit が先に来る fixedWindow / slidingWindow と並びが違います)。また Upstash のドキュメントは、トークンバケットは計算量の面で高価であり、MultiRegionRatelimit では未サポートであると明記しています。グローバル分散が必要ならスライディングウィンドウに切り替える判断が要ります。

さらに LLM 特有の要件として、limit()rate オプションによる重み付き消費があります。

// 長いプロンプトや高性能モデルの指定は、より多くのトークンを消費させる
await limiter.limit(identifier, { rate: estimateCost(input, model) });

「リクエスト数」ではなく「原価」でバケツを削る。これが、モデル蒸留のような総トークン量で殴ってくる攻撃への対抗策です。


6. 実装 — 型安全でフェイルセーフな4ファイル

以降のコードは TypeScript strict 前提で、any と型キャスト(as)を使用していません。外部入力(環境変数・リクエストボディ・外部APIレスポンス)はすべて境界で Zod により検証・ナローイングします。

6.1 基盤モジュール — 環境変数の検証と構造化ログ

設計意図: process.env.FOO! は型システムに対する嘘です。存在しない環境変数は undefined として実行時まで生き延び、本番で初めて Cannot read properties of undefined になります。起動時に一度だけ検証し、以降は完全に型付けされた値だけを流通させます(フェイルファスト)。

// lib/env.ts
import "server-only";
import { z } from "zod";

/**
 * サーバー専用の環境変数。モジュール読み込み時に一度だけ検証し、
 * 不正ならプロセスを起動させない(フェイルファスト)。
 */
const serverEnvSchema = z.object({
  // Vercel Marketplace の Upstash 統合が自動注入する
  UPSTASH_REDIS_REST_URL: z.url(),
  UPSTASH_REDIS_REST_TOKEN: z.string().min(1),

  // Cloudflare Turnstile のシークレット(クライアントへ渡してはならない)
  TURNSTILE_SECRET_KEY: z.string().min(1),

  // 匿名セッションIDの署名鍵。32バイト以上のランダム値を推奨
  SESSION_SECRET: z.string().min(32),

  // 推論プロバイダ。Vercel AI Gateway 経由なら "provider/model" 形式
  CHAT_MODEL: z.string().min(1).default("anthropic/claude-sonnet-4.5"),

  // 環境の識別(構造化ログのフィールドに使う)
  VERCEL_ENV: z.enum(["production", "preview", "development"]).default("development"),
});

const parsed = serverEnvSchema.safeParse(process.env);

if (!parsed.success) {
  // 値そのものは絶対に出力しない。欠落しているキー名だけを報告する。
  const invalidKeys = [...new Set(parsed.error.issues.map((issue) => issue.path.join(".")))];
  throw new Error(`[env] Invalid server environment variables: ${invalidKeys.join(", ")}`);
}

export const serverEnv = parsed.data;
// lib/env.client.ts
import { z } from "zod";

/**
 * クライアントへ露出してよい公開値のみ。
 * NEXT_PUBLIC_* は静的置換されるため、プロパティアクセスを直書きする必要がある。
 */
const clientEnvSchema = z.object({
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1),
});

export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
});

なぜファイルを2つに分けるのか: 1つにまとめると、クライアントコンポーネントが公開キー目当てにインポートした瞬間、スキーマに含まれるシークレットのキー名がクライアントバンドルの静的解析対象になります。import "server-only" は、サーバー専用モジュールがクライアント側に混入したときビルドを失敗させる保険です。

そしてログ。console.error("失敗しました") は本番では何の役にも立ちません。将来 Sentry や Log Drain へ流すことを前提に、最初から構造化しておきます。出力先を1箇所に閉じ込めておけば、移行はこのファイルの中だけで済みます(SRP)。

// lib/observability.ts
type LogLevel = "info" | "warn" | "error";

/**
 * 構造化ログの1レコード。値は文字列・数値・真偽値・null に限定する。
 * オブジェクトを許すと、うっかりリクエストボディごと出力する事故が起きる。
 */
interface LogFields {
  readonly level: LogLevel;
  /** ドット区切りのイベント名(例: "rate_limit.blocked")。集計のキーになる */
  readonly event: string;
  readonly [key: string]: string | number | boolean | null | undefined;
}

/**
 * PII とシークレットを載せないことは呼び出し側の責務。
 * ここでは「何が起きたか」の識別子と、非PIIの計測値だけを流す。
 */
export function logEvent(fields: LogFields): void {
  const line = JSON.stringify({ ...fields, timestamp: new Date().toISOString() });

  // 将来 Sentry を入れる際は、この分岐の中だけを差し替えればよい。
  if (fields.level === "error") {
    console.error(line);
    return;
  }
  if (fields.level === "warn") {
    console.warn(line);
    return;
  }
  console.info(line);
}

6.2 lib/rate-limit.ts — 2本のリミッタと観測可能な結果

設計意図: レート制限の「ポリシー」と「実行」を分離します(SRP)。ポリシーは定数として一箇所に集約し、マジックナンバーを排除。実行側は失敗の扱い(フェイルオープン)を1箇所に閉じ込め、呼び出し側に例外処理を強要しません。

// lib/rate-limit.ts
import "server-only";
import { Ratelimit, type Duration } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { serverEnv } from "./env";
import { logEvent } from "./observability";

const redis = new Redis({
  url: serverEnv.UPSTASH_REDIS_REST_URL,
  token: serverEnv.UPSTASH_REDIS_REST_TOKEN,
});

/**
 * ホットなインスタンス内で「拒否済み」の識別子を記憶し、Redis への往復を省く。
 * 注意: これは最適化であって正しさの機構ではない。Next.js の Proxy は
 * グローバル状態への依存を明示的に禁じており、消えても動作は変わらない
 * (Redis に問い合わせ直すだけ)。片方向の最適化として扱うこと。
 */
const ephemeralCache = new Map<string, number>();

interface TokenBucketPolicy {
  /** interval ごとに補充されるトークン数(=長期的な平均レート) */
  readonly refillRate: number;
  /** 補充の間隔 */
  readonly interval: Duration;
  /** バケツの最大容量(=許容するバーストの大きさ) */
  readonly maxTokens: number;
  /** Redis キーの名前空間。層ごとに必ず分ける */
  readonly prefix: string;
}

/** 上限値のSSoT。運用中の調整はこのオブジェクトだけを触る。 */
const POLICIES: Readonly<Record<"network" | "session", TokenBucketPolicy>> = {
  // 粗い網: 発信元ネットワーク単位。Cookie を捨てても効く防御の背骨。
  network: { refillRate: 60, interval: "60 s", maxTokens: 90, prefix: "rl:net" },
  // 細かい網: セッション単位。1利用者の体感を守る公平性の装置。
  session: { refillRate: 10, interval: "60 s", maxTokens: 20, prefix: "rl:sess" },
};

function createLimiter(policy: TokenBucketPolicy): Ratelimit {
  return new Ratelimit({
    redis,
    limiter: Ratelimit.tokenBucket(policy.refillRate, policy.interval, policy.maxTokens),
    prefix: policy.prefix,
    ephemeralCache,
    // Redis が応答しない場合、この時間を超えたリクエストは通す(フェイルオープン)。
    // 可用性 > 厳密性。レート制限の障害でサービスを止めない。
    timeout: 1_000,
    // 本番のみ分析を有効化。Redis コマンドを追加消費するため開発では切る。
    analytics: serverEnv.VERCEL_ENV === "production",
  });
}

const limiters: Readonly<Record<keyof typeof POLICIES, Ratelimit>> = {
  network: createLimiter(POLICIES.network),
  session: createLimiter(POLICIES.session),
};

export interface RateLimitVerdict {
  readonly allowed: boolean;
  /** 拒否した層。監視とレスポンスヘッダに使う */
  readonly limitedBy: keyof typeof POLICIES | null;
  /** 再試行可能になるまでの秒数(Retry-After 用) */
  readonly retryAfterSeconds: number;
  /** 完了を待つべき非同期処理(analytics の書き込みなど) */
  readonly pending: readonly Promise<unknown>[];
}

const ALLOWED: RateLimitVerdict = {
  allowed: true,
  limitedBy: null,
  retryAfterSeconds: 0,
  pending: [],
};

export interface RateLimitInput {
  /** 粗い網の識別子(IPv4 完全アドレス / IPv6 は /64 プレフィックス) */
  readonly networkId: string;
  /** 細かい網の識別子(署名済みCookie のセッションID) */
  readonly sessionId: string;
  /** 1リクエストで消費するトークン数。入力長やモデルで重み付けする */
  readonly cost: number;
}

/**
 * 2本のリミッタを AND で評価する。粗い網を先に引くのは、
 * 攻撃トラフィックを最小のコストで棄却するため(フェイルファスト)。
 */
export async function checkRateLimit(input: RateLimitInput): Promise<RateLimitVerdict> {
  const checks: readonly [keyof typeof POLICIES, string][] = [
    ["network", input.networkId],
    ["session", input.sessionId],
  ];

  const pending: Promise<unknown>[] = [];

  for (const [layer, identifier] of checks) {
    try {
      const result = await limiters[layer].limit(identifier, { rate: input.cost });
      pending.push(result.pending);

      if (!result.success) {
        return {
          allowed: false,
          limitedBy: layer,
          retryAfterSeconds: Math.max(1, Math.ceil((result.reset - Date.now()) / 1_000)),
          pending,
        };
      }
    } catch (error) {
      // フェイルオープン: レート制限基盤の障害を、サービス全体の障害にしない。
      // ただし沈黙させず、必ず異常として記録する(L4 のプロバイダ上限が最後の砦)。
      logEvent({
        level: "error",
        event: "rate_limit.unavailable",
        layer,
        message: error instanceof Error ? error.message : "unknown error",
      });
      return ALLOWED;
    }
  }

  return { ...ALLOWED, pending };
}

フェイルオープンの是非について。 レート制限が壊れたときリクエストを通す(オープン)か止める(クローズ)かは、思想ではなく計算で決めます。ここでは通しています。理由は「Redis 障害でチャット全体が停止する損失」>「その数分間に濫用される推論コスト」だから。ただしこれは L4 のプロバイダ側予算上限が存在することを前提にした判断です。天井がないならフェイルクローズにすべきです。フェイルオープンは、別の場所に天井がある場合にのみ許される選択です。

6.3 lib/session.ts — 改ざん不能な匿名セッション

設計意図: ログイン不要でも「同一利用者らしさ」の識別子は必要です。ただし平文のランダムIDでは、攻撃者が値を変えるだけでバケツを増やせます。HMAC 署名を付けてサーバーが発行したIDであることを検証可能にし、偽造されたIDはセッション層の制限を通さず即座に弾きます。

// lib/session.ts
import "server-only";
import { serverEnv } from "./env";

const SESSION_COOKIE = "aichat_sid";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; // 24時間

/** Web Crypto のみを使用(Node.js / Edge の両ランタイムで同一コードが動く)。 */
async function hmac(payload: string): Promise<string> {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(serverEnv.SESSION_SECRET),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
  return Array.from(new Uint8Array(signature))
    .map((byte) => byte.toString(16).padStart(2, "0"))
    .join("");
}

/** タイミング攻撃を避ける定数時間比較。 */
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i += 1) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

export async function issueSessionId(): Promise<string> {
  const id = crypto.randomUUID();
  return `${id}.${await hmac(id)}`;
}

/** 署名が正しいときだけ ID を返す。偽造・欠落は null。 */
export async function verifySessionId(value: string | undefined): Promise<string | null> {
  if (!value) return null;
  const separatorIndex = value.lastIndexOf(".");
  if (separatorIndex <= 0) return null;

  const id = value.slice(0, separatorIndex);
  const signature = value.slice(separatorIndex + 1);
  const expected = await hmac(id);

  return timingSafeEqual(signature, expected) ? id : null;
}

interface SessionCookieSpec {
  readonly name: string;
  readonly options: {
    readonly httpOnly: true;
    readonly secure: true;
    /** JSはCookieを読まない(httpOnly)。CSRF面を狭めるため lax 固定 */
    readonly sameSite: "lax";
    readonly path: "/";
    readonly maxAge: number;
  };
}

export const sessionCookie: SessionCookieSpec = {
  name: SESSION_COOKIE,
  options: {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: SESSION_MAX_AGE_SECONDS,
  },
};

6.4 proxy.ts — 早期棄却と、セッションの発行

設計意図: ユーザーの要望どおり「入口でのレート制限」を担いますが、Next.js 16 の正しいファイル規約(proxy.ts / export function proxy に従います。ここでの役割は2つに絞ります——(1) 粗い網での早期棄却、(2) 匿名セッションの発行。本文のパースや推論に関わる判断は Route Handler の責務です(SRP)。

// proxy.ts (Next.js 16。旧 middleware.ts。関数名も proxy に変わる)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { checkRateLimit } from "@/lib/rate-limit";
import { issueSessionId, sessionCookie, verifySessionId } from "@/lib/session";
import { clientIp, networkKey } from "@/lib/client-identifier";
import { logEvent } from "@/lib/observability";

/**
 * 保護対象を明示する。matcher を広げるほど、静的アセットにまで
 * 課金対象の実行が乗る。ここでは推論エンドポイントとチャット画面のみ。
 */
export const config = {
  matcher: ["/api/chat", "/chat"],
};

/** 1リクエストの基準コスト。実際の重み付けは Route Handler で再評価する。 */
const BASE_COST = 1;

export async function proxy(request: NextRequest): Promise<NextResponse> {
  const networkId = networkKey(clientIp(request.headers));

  // 署名検証に通らない Cookie は「無い」ものとして扱い、新規発行する。
  const existingSessionId = await verifySessionId(
    request.cookies.get(sessionCookie.name)?.value,
  );
  const sessionId = existingSessionId ?? (await issueSessionId());

  // 新規発行のセッションは定義上どのバケツも消費していないため、
  // セッション層は必ず通過する。Cookie を捨て続ける攻撃者を捕まえるのは
  // 常にネットワーク層の役目である——2本を独立させる理由がこれ。
  const verdict = await checkRateLimit({
    networkId,
    // 署名込みの文字列ではなく、生のID部分をキーにする
    sessionId: existingSessionId ?? sessionId.split(".")[0],
    cost: BASE_COST,
  });

  if (!verdict.allowed) {
    logEvent({
      level: "warn",
      event: "rate_limit.blocked",
      layer: verdict.limitedBy,
      path: request.nextUrl.pathname,
    });

    return NextResponse.json(
      {
        error: "rate_limited",
        message: "リクエストが多すぎます。少し時間をおいて再度お試しください。",
        retryAfterSeconds: verdict.retryAfterSeconds,
      },
      {
        status: 429,
        headers: { "Retry-After": String(verdict.retryAfterSeconds) },
      },
    );
  }

  const response = NextResponse.next();

  if (!existingSessionId) {
    response.cookies.set({
      name: sessionCookie.name,
      value: sessionId,
      ...sessionCookie.options,
    });
  }

  return response;
}
// lib/client-identifier.ts
const IPV6_PREFIX_SEGMENTS = 4; // /64 = 16bit × 4

/**
 * クライアントの実IP。Vercel は x-forwarded-for を上書きし、外部からの
 * 詐称値を転送しないため、このヘッダはプラットフォームが保証する値として
 * 扱える。自前プロキシを前段に置く構成では x-vercel-forwarded-for を優先。
 *
 * 戻り値は「そのままAPIに渡してよい実アドレス」。取得できなければ null。
 */
export function clientIp(headers: Headers): string | null {
  const raw =
    headers.get("x-vercel-forwarded-for") ??
    headers.get("x-forwarded-for") ??
    headers.get("x-real-ip");

  if (!raw) return null;

  const address = raw.split(",")[0].trim();
  return address.length > 0 ? address : null;
}

/**
 * レート制限のキー用に IP を丸める。実IPとは用途が違うので関数を分ける
 * (丸めた値は不完全なアドレスであり、外部APIに渡してはならない)。
 *
 * IPv6 は /64 で丸める。完全アドレスで数えると、末端ユーザーですら
 * 2^64 個のアドレスを持つため、制限を無限に回避されてしまう。
 */
export function networkKey(ip: string | null): string {
  if (!ip) return "unknown";
  if (!ip.includes(":")) return ip;
  return ip.split(":").slice(0, IPV6_PREFIX_SEGMENTS).join(":");
}

middleware.ts のまま運用しているプロジェクトは、export function middleware(...) に置き換えれば同じコードが動きます(Next.js 16 では非推奨・将来削除)。移行はコドモッド1行で済みます。

6.5 lib/turnstile.ts — 外部APIレスポンスを信用しない

設計意図: siteverify の戻り値は外部入力です。response.json() の結果を型注釈だけで信じるのは、実質的な any の再導入にほかなりません。Zod で検証し、successtrue であることに加えて actionhostname の一致まで確認します。これを怠ると、攻撃者が別サイトに設置した同一サイトキーのウィジェットで取得したトークンを流用できます。

// lib/turnstile.ts
import "server-only";
import { z } from "zod";
import { serverEnv } from "./env";
import { logEvent } from "./observability";

const SITEVERIFY_ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const VERIFY_TIMEOUT_MS = 3_000;

/** 公式が定める siteverify のレスポンス形。未知のキーは無視する。 */
const siteverifyResponseSchema = z.object({
  success: z.boolean(),
  "error-codes": z.array(z.string()).default([]),
  challenge_ts: z.string().optional(),
  hostname: z.string().optional(),
  action: z.string().optional(),
  cdata: z.string().optional(),
});

export type TurnstileFailureReason =
  | "missing-token"
  | "invalid-token"
  | "expired-or-duplicate"
  | "action-mismatch"
  | "verification-unavailable";

export type TurnstileResult =
  | { readonly ok: true }
  | { readonly ok: false; readonly reason: TurnstileFailureReason };

interface VerifyParams {
  readonly token: string | undefined;
  /** クライアントの実IP。丸めた値ではなく完全なアドレスを渡すこと */
  readonly remoteIp: string | null;
  /** ウィジェット側で指定した action。値の一致を必ず確認する */
  readonly expectedAction: string;
  /** 自サイトのホスト名。他所で取得したトークンの流用を防ぐ */
  readonly expectedHostname: string;
  /** 再試行しても二重消費にならないための冪等キー */
  readonly idempotencyKey: string;
}

export async function verifyTurnstile(params: VerifyParams): Promise<TurnstileResult> {
  if (!params.token) return { ok: false, reason: "missing-token" };

  const body = new FormData();
  body.append("secret", serverEnv.TURNSTILE_SECRET_KEY);
  body.append("response", params.token);
  body.append("idempotency_key", params.idempotencyKey);
  // remoteip は任意項目。不正な値を送ると bad-request で弾かれるため、
  // 実アドレスが取れたときだけ付ける。
  if (params.remoteIp !== null) {
    body.append("remoteip", params.remoteIp);
  }

  let payload: unknown;
  try {
    const response = await fetch(SITEVERIFY_ENDPOINT, {
      method: "POST",
      body,
      signal: AbortSignal.timeout(VERIFY_TIMEOUT_MS),
    });
    payload = await response.json();
  } catch (error) {
    // Cloudflare 側の障害・タイムアウト。ボット検証は「安全側 = 拒否」に倒す。
    // レート制限(可用性優先でオープン)とは逆の判断であることに注意。
    logEvent({
      level: "error",
      event: "turnstile.unavailable",
      message: error instanceof Error ? error.message : "unknown error",
    });
    return { ok: false, reason: "verification-unavailable" };
  }

  const parsed = siteverifyResponseSchema.safeParse(payload);
  if (!parsed.success) {
    logEvent({ level: "error", event: "turnstile.malformed_response" });
    return { ok: false, reason: "verification-unavailable" };
  }

  const result = parsed.data;

  if (!result.success) {
    // timeout-or-duplicate は「300秒超過」か「トークンの再利用」を意味する。
    // ユーザーには再試行を促し、攻撃としては扱わない。
    const isReuse = result["error-codes"].includes("timeout-or-duplicate");
    logEvent({
      level: "warn",
      event: "turnstile.rejected",
      codes: result["error-codes"].join(","),
    });
    return { ok: false, reason: isReuse ? "expired-or-duplicate" : "invalid-token" };
  }

  // success: true だけでは不十分。どのウィジェットで・どのドメインで
  // 取得されたトークンなのかを照合して初めて意味を持つ。
  if (result.action !== params.expectedAction || result.hostname !== params.expectedHostname) {
    logEvent({
      level: "warn",
      event: "turnstile.context_mismatch",
      action: result.action ?? "none",
      hostname: result.hostname ?? "none",
    });
    return { ok: false, reason: "action-mismatch" };
  }

  return { ok: true };
}

Turnstile の仕様で必ず押さえるべき点は3つです。トークンは発行から300秒(5分)で失効し、検証は1回限り。再利用は timeout-or-duplicate として拒否されます。したがってクライアント側には「トークンが期限切れになったら自動で取り直す」実装が必須です(次節の expired-callback)。そして idempotency_key は、ネットワーク障害でレスポンスを取り逃した際に同じトークンで安全に再検証するための仕組みです。

6.6 app/api/chat/route.ts — 推論を呼ぶ前に、すべて終わらせる

設計意図: LLM の呼び出しは、このシステムで最も高価な操作です。したがってそこへ到達する前にすべての棄却理由を潰し切る(フェイルファスト)。検証の順序は「安いものから順に」——本文サイズ → スキーマ → Turnstile(外部API1往復)→ セッション単位のレート制限 → 推論、と原価の昇順に並べます。

// app/api/chat/route.ts
import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream } from "ai";
import { z } from "zod";
import { after } from "next/server";
import { serverEnv } from "@/lib/env";
import { verifyTurnstile } from "@/lib/turnstile";
import { checkRateLimit } from "@/lib/rate-limit";
import { verifySessionId, sessionCookie } from "@/lib/session";
import { clientIp, networkKey } from "@/lib/client-identifier";
import { logEvent } from "@/lib/observability";

export const maxDuration = 30;

const TURNSTILE_ACTION = "chat-message";
const MAX_BODY_BYTES = 16 * 1024;
const MAX_MESSAGES = 40;

/** 受け付ける本文の形。UIメッセージの必要最小限だけを許可する。 */
const requestSchema = z.object({
  turnstileToken: z.string().min(1).max(2048),
  messages: z
    .array(
      z.object({
        id: z.string().min(1).max(128),
        role: z.enum(["user", "assistant", "system"]),
        parts: z
          .array(z.object({ type: z.literal("text"), text: z.string().min(1).max(4_000) }))
          .min(1)
          .max(8),
      }),
    )
    .min(1)
    .max(MAX_MESSAGES),
});

/** 入力の総文字数からトークンバケットの消費量を見積もる(重み付き課金)。 */
function estimateCost(text: string): number {
  return Math.max(1, Math.ceil(text.length / 500));
}

function errorResponse(status: number, code: string, message: string, retryAfter?: number): Response {
  return Response.json(
    { error: code, message },
    {
      status,
      headers: retryAfter === undefined ? undefined : { "Retry-After": String(retryAfter) },
    },
  );
}

export async function POST(request: Request): Promise<Response> {
  // --- 検証1: 本文サイズ(最も安いチェックを最初に) ---
  // これは善意のクライアント向けの早期棄却にすぎない(ヘッダは詐称できる)。
  // 実効的な上限は次のスキーマ検証(文字数上限・配列長上限)が担保する。
  const contentLength = Number(request.headers.get("content-length") ?? "0");
  if (contentLength > MAX_BODY_BYTES) {
    return errorResponse(413, "payload_too_large", "メッセージが長すぎます。");
  }

  // --- 検証2: スキーマ(推論を呼ぶ前に構造を確定させる) ---
  const parsed = requestSchema.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return errorResponse(400, "invalid_request", "リクエストの形式が正しくありません。");
  }
  const { turnstileToken, messages } = parsed.data;

  // --- 検証3: セッションの真正性(偽造Cookieはここで落ちる) ---
  const cookieHeader = request.headers.get("cookie") ?? "";
  const rawSession = cookieHeader
    .split(";")
    .map((part) => part.trim())
    .find((part) => part.startsWith(`${sessionCookie.name}=`))
    ?.slice(sessionCookie.name.length + 1);

  const sessionId = await verifySessionId(rawSession);
  if (!sessionId) {
    return errorResponse(401, "invalid_session", "セッションが無効です。ページを再読み込みしてください。");
  }

  const ip = clientIp(request.headers);
  const networkId = networkKey(ip);

  // --- 検証4: Turnstile(外部API 1往復。ここまでは自前で完結している) ---
  const turnstile = await verifyTurnstile({
    token: turnstileToken,
    remoteIp: ip,
    expectedAction: TURNSTILE_ACTION,
    expectedHostname: new URL(request.url).hostname,
    idempotencyKey: `${sessionId}:${messages[messages.length - 1].id}`,
  });

  if (!turnstile.ok) {
    const status = turnstile.reason === "verification-unavailable" ? 503 : 403;
    return errorResponse(status, turnstile.reason, "認証に失敗しました。もう一度お試しください。");
  }

  // --- 検証5: 重み付きレート制限(推論の直前・最後の関門) ---
  const inputText = messages
    .flatMap((message) => message.parts.map((part) => part.text))
    .join("");
  const cost = estimateCost(inputText);

  const verdict = await checkRateLimit({ networkId, sessionId, cost });

  if (!verdict.allowed) {
    return errorResponse(
      429,
      "rate_limited",
      "利用が集中しています。少し時間をおいて再度お試しください。",
      verdict.retryAfterSeconds,
    );
  }

  // analytics などの後処理はレスポンスを止めずに完了させる
  after(() => Promise.allSettled(verdict.pending));

  // --- ここまで通過して初めて、課金される推論を呼ぶ ---
  logEvent({ level: "info", event: "chat.accepted", cost });

  const result = streamText({
    model: serverEnv.CHAT_MODEL,
    messages: await convertToModelMessages(messages),
    onError: ({ error }) => {
      logEvent({
        level: "error",
        event: "chat.inference_failed",
        message: error instanceof Error ? error.message : "unknown error",
      });
    },
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}

ストリーミングに runtime = "edge" は不要です。 これは非常に多く見られる誤解ですが、ReadableStream も SSE も AI のトークンストリーミングも、既定の Node.js ランタイム(Fluid Compute)で何の設定もなく動きます。Edge を選ぶと Node.js API が使えなくなり、実行時間の上限も厳しくなるため、むしろ不利です。

6.7 components/chat-input.tsx — a11y と再試行を織り込んだUI

設計意図: Turnstile は「見えない」からこそ、失敗したときにユーザーが何が起きたか分かりません。トークンの期限切れ(300秒)・検証エラー・レート制限(429)を区別して伝え、区別して回復させるのがこのコンポーネントの責務です。

// components/chat-input.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import Script from "next/script";
import { clientEnv } from "@/lib/env.client";

const TURNSTILE_SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const TURNSTILE_ACTION = "chat-message";

/** 明示レンダリング用の最小限の型。`any` を避けるため自前で宣言する。 */
interface TurnstileRenderOptions {
  sitekey: string;
  action: string;
  appearance: "always" | "execute" | "interaction-only";
  callback: (token: string) => void;
  "error-callback": () => void;
  "expired-callback": () => void;
}

interface TurnstileApi {
  render: (container: HTMLElement, options: TurnstileRenderOptions) => string;
  reset: (widgetId: string) => void;
  remove: (widgetId: string) => void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

type Status = "loading-challenge" | "ready" | "sending" | "error";

interface ChatInputProps {
  readonly onSend: (input: { text: string; turnstileToken: string }) => Promise<void>;
  readonly disabled?: boolean;
}

export function ChatInput({ onSend, disabled = false }: ChatInputProps) {
  const [status, setStatus] = useState<Status>("loading-challenge");
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const [value, setValue] = useState("");

  const tokenRef = useRef<string | null>(null);
  const widgetIdRef = useRef<string | null>(null);
  const containerRef = useRef<HTMLDivElement | null>(null);

  const inputId = useId();
  const errorId = useId();

  const renderWidget = useCallback(() => {
    const container = containerRef.current;
    const turnstile = window.turnstile;
    if (!container || !turnstile || widgetIdRef.current !== null) return;

    widgetIdRef.current = turnstile.render(container, {
      sitekey: clientEnv.NEXT_PUBLIC_TURNSTILE_SITE_KEY,
      action: TURNSTILE_ACTION,
      // 対話が必要なときだけウィジェットを表示する(通常は不可視)
      appearance: "interaction-only",
      callback: (token) => {
        tokenRef.current = token;
        setStatus("ready");
        setErrorMessage(null);
      },
      "error-callback": () => {
        tokenRef.current = null;
        setStatus("error");
        setErrorMessage("セキュリティ確認に失敗しました。ページを再読み込みしてください。");
      },
      // トークンは300秒で失効する。黙って壊れるのを防ぐため必ず取り直す。
      "expired-callback": () => {
        tokenRef.current = null;
        setStatus("loading-challenge");
        if (widgetIdRef.current !== null) window.turnstile?.reset(widgetIdRef.current);
      },
    });
  }, []);

  useEffect(() => {
    return () => {
      const widgetId = widgetIdRef.current;
      if (widgetId !== null) window.turnstile?.remove(widgetId);
    };
  }, []);

  const isBusy = status === "sending" || status === "loading-challenge";
  const canSubmit = status === "ready" && value.trim().length > 0 && !disabled;

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const token = tokenRef.current;
    if (!canSubmit || !token) return;

    setStatus("sending");
    setErrorMessage(null);

    try {
      await onSend({ text: value.trim(), turnstileToken: token });
      setValue("");
    } catch (error) {
      setStatus("error");
      setErrorMessage(
        error instanceof Error ? error.message : "送信に失敗しました。時間をおいてお試しください。",
      );
    } finally {
      // トークンは1回限り。成否にかかわらず必ず次の分を取り直す。
      tokenRef.current = null;
      if (widgetIdRef.current !== null) {
        window.turnstile?.reset(widgetIdRef.current);
        setStatus("loading-challenge");
      }
    }
  };

  return (
    <form onSubmit={handleSubmit} className="flex flex-col gap-2">
      <Script src={TURNSTILE_SCRIPT} strategy="afterInteractive" onReady={renderWidget} />

      <label htmlFor={inputId} className="text-sm font-medium">
        メッセージ
      </label>

      <textarea
        id={inputId}
        value={value}
        onChange={(event) => setValue(event.target.value)}
        onKeyDown={(event) => {
          // Enter で送信 / Shift+Enter で改行(IME変換中は送信しない)
          if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
            event.preventDefault();
            event.currentTarget.form?.requestSubmit();
          }
        }}
        rows={3}
        maxLength={4000}
        disabled={disabled || status === "sending"}
        aria-describedby={errorMessage ? errorId : undefined}
        aria-invalid={status === "error"}
        className="w-full resize-none rounded-md border border-neutral-300 p-3 disabled:opacity-60"
        placeholder="質問を入力してください"
      />

      {/* 不可視チャレンジのマウント先。対話が必要なときだけ内容が描画される */}
      <div ref={containerRef} />

      {/* エラーは支援技術にも即座に伝える */}
      <p id={errorId} role="alert" aria-live="assertive" className="min-h-5 text-sm text-red-600">
        {errorMessage}
      </p>

      <button
        type="submit"
        disabled={!canSubmit}
        aria-busy={isBusy}
        className="self-end rounded-md bg-neutral-900 px-4 py-2 text-white disabled:opacity-50"
      >
        {status === "sending" ? "送信中…" : status === "loading-challenge" ? "確認中…" : "送信"}
      </button>
    </form>
  );
}

a11y の要点は4つです。<label> と入力の明示的な関連付け、aria-invalidaria-describedby によるエラーの結び付け、role="alert" + aria-live="assertive" による画面を見ていない利用者へのエラー通知、そして aria-busy による処理中状態の伝達。加えて isComposing の判定は、日本語入力で変換確定の Enter が誤送信になる事故を防ぎます——日本語UIでは事実上必須です。


7. データ・グラビティ — なぜレート制限は「ズレてよい」のか

TL;DR: 一貫性は強ければ強いほど良い、ではありません。強い一貫性は「合意」というネットワーク往復を必ず伴い、その遅延を毎リクエスト払うことになります。判断基準は『すり抜け1回の損失額』です。

7.1 CAP の現実的な読み方

CAP 定理は「一貫性(C)・可用性(A)・分断耐性(P)のうち2つしか選べない」と要約されますが、実務での意味はもっと単純です。ネットワーク分断は必ず起きる(P は選択肢ではない)。だから実質的な選択は「分断中に、古いデータを返してでも応答するか(AP)/エラーを返してでも正しさを守るか(CP)」の二択です。

この選択を「アプリ全体」で1回決めるのが最大の誤りです。正しくはデータの種類ごとに決めます。

データ1回間違えたときの損失正しい選択具体例
レート制限カウンタ推論1回分(数円)AP(結果整合性)Upstash Redis
チャット履歴・ログ表示が数百ms古いAPRedis / DynamoDB
課金・残高・クレジット実損(二重課金・マイナス残高)CP(強い一貫性)CockroachDB / RDBMS
認証・権限セキュリティ侵害CP同上

「レート制限を CockroachDB で強い一貫性を持って管理する」は技術的には可能ですが、すべてのチャットリクエストに Raft の合意ラウンド(数十ms〜)を上乗せすることを意味します。守る対象が数円の推論コストであることを考えれば、明確な過剰設計です。逆に「クレジット残高を Redis で管理する」は、レプリケーション遅延の窓で残高がマイナスになりうる——こちらは過小設計です。

7.2 3つのストアを、アーキテクチャの原理で比べる

「速い・遅い」ではなく、なぜそうなるのかで比較します。

Redis(Upstash)— インメモリ・直列実行

データが RAM 上にあり、ディスクI/Oが判断経路に入りません。そしてコマンドを1つずつ順に実行するモデルであるため、INCR や Lua スクリプトがロックなしで原子的に成立します。レート制限のカウンタ更新(読む→足す→書く)が並行リクエストで壊れないのは、この性質のおかげです。

代償はメモリコストと耐久性です。RAM は SSD より桁違いに高価なので、TTL と maxmemory の追い出しポリシー(allkeys-lru / volatile-ttl など)の設計が必須になります。レート制限用途では TTL が自然に効くため、volatile-ttl 系との相性が良好です。カウンタが最悪失われても、次のウィンドウで作り直せばよい——この「失っても壊れない」性質こそが、レート制限を Redis に置ける理由です。

CockroachDB — Raft + HLC による分散SQL

データはレンジという単位に分割され、それぞれが複数のレプリカとして分散配置されます。各レンジのレプリカ群は Raft グループを構成し、書き込みは過半数(クォーラム)の合意を得て初めてコミットされます。既定のレプリケーション係数は3で、1ノードの障害に耐えます(5なら2ノード)。

読み取りはリースホルダーという単一のレプリカが担当し、Leader Leases により Raft リーダーとリースホルダーを一致させることで、強い一貫性を持つ読み取りに余分な合意ラウンドを不要にしています。書き込み側では Parallel Commits により、コミットのレイテンシを2ラウンドの合意から1ラウンドへ削減しています。

分散システムで最も厄介な「どちらのイベントが先か」の問題には、HLC(Hybrid Logical Clocks) で対処します。物理時刻(常にローカルの壁時計に近い)と論理カウンタ(同一物理時刻のイベントを区別する)を組み合わせたタイムスタンプです。Google Spanner が原子時計とGPSによる TrueTime に依存するのに対し、CockroachDB は限界が定まった半同期のクロックに依拠します。既定の最大クロックオフセットは 500ms(マルチリージョン構成では 250ms が推奨)で、あるノードが半数以上のノードと最大オフセットの80%を超えてずれていると検知した場合、そのノードは即座に自らクラッシュします。一貫性を守るために可用性を犠牲にする、というCPの立場が実装として現れた挙動です。

DynamoDB — ディスクベース・多AZレプリケーション

SSD 上にデータを保持し、複数のアベイラビリティゾーンにレプリケーションします。ここで最も重要な仕様は「結果整合性が既定である」ことです。

  • 既定の読み取りは結果整合性。直前の書き込みが反映されていない可能性がある。
  • ConsistentRead: true を指定すると強い一貫性の読み取りになるが、コストは結果整合性読み取りの2倍
  • 強い一貫性読み取りはテーブルと LSI でのみ利用可能。GSI とストリームからの読み取りは常に結果整合性。
  • グローバルテーブルは既定が MREC(マルチリージョン結果整合性、通常1秒以内で伝播)。同期レプリケーションを行う MRSC(マルチリージョン強一貫性)も選択できる。

つまり DynamoDB は「一貫性の強さを読み取りごとに、コストと引き換えに買える」設計です。GSI に強い一貫性がないという制約は、設計時に必ず効いてきます。

7.3 選定表

観点Upstash RedisCockroachDBDynamoDB
データ配置インメモリディスク(分散)SSD(多AZ)
整合性モデル単一リージョンは実質アトミック/レプリカは結果整合性直列化可能(Serializable)既定は結果整合性、読み取り単位で強一貫性を選択可
合意機構なし(単一プライマリ)Raft(過半数クォーラム)内部レプリケーション
クロック依存なしHLC(既定 max-offset 500ms)なし
主なレイテンシ要因ネットワークRTTのみ合意ラウンドストレージ+レプリケーション
エッジからの接続HTTP(最適)要ドライバ(TCP)要SDK/HTTP API
クエリKV・データ構造SQL・JOIN・トランザクションKV・限定的クエリ
このシステムでの役割レート制限、セッション、キャッシュ課金、クレジット残高、監査会話ログ、大量の追記データ

組み合わせて使うのが正解です。 「1つのDBですべてを賄う」は運用が単純になる反面、必ずどこかで過剰か過小になります。データ・グラビティ(データは重く、動かしにくい)を前提にすれば、最初にデータの性質を分類し、それぞれに適したストアを割り当てるほうが、後からの移行コストを避けられます。


8. 本番投入前チェックリスト

  • L4 の天井があるか。 プロバイダ側(AI Gateway 等)に予算・使用量の上限を設定した。コードが全滅しても請求は止まる。
  • @vercel/kv を使っていない。Marketplace の Upstash 統合で環境変数が注入されている。
  • middleware.ts ではなく proxy.ts(Next.js 16)。export function proxy になっている。
  • Proxy の matcher が保護対象だけに絞られている(静的アセットに実行が乗っていない)。
  • レート制限のキーが「IP+セッション」の連結ではなく、独立した2本の AND になっている。
  • IPv6 を /64 で丸めている。
  • Turnstile をサーバー側で siteverify 検証している。success に加えて actionhostname を照合している。
  • Turnstile トークンの expired-callback(300秒)を実装し、送信の成否にかかわらず reset() している。
  • レート制限はフェイルオープン、ボット検証はフェイルクローズ。この非対称が意図的だと説明できる。
  • 429 に Retry-After を付けている。UI が待ち時間を表示する。
  • 拒否・障害の構造化ログが出ている(PIIとシークレットを含まない)。
  • Vercel WAF のレート制限ルールを、まず Log アクションで数日観測してから Deny に切り替えた。
  • 「請求額」そのものにアラートを設定した。エラー率とレイテンシは、この攻撃では上がらない。

最後の項目を強調しておきます。この攻撃の唯一確実な検知手段は、金額の監視です。 リクエスト数・トークン消費量・推定コストを時系列で可視化し、平常時の3倍などの閾値でアラートを飛ばしてください。


9. まとめ

ログイン不要の生成AIチャットを守るとは、「誰が使えるか」を制御することではなく、「どれだけ使えるか」の天井を、独立した複数の場所に置くことです。

  1. 脅威は請求書。可用性の指標は正常なまま被害が進む。監視すべきは金額。
  2. 2026年の部品表を更新する。Vercel KV は廃止、middleware.tsproxy.ts(Node.js 既定)、面の防御は WAF。
  3. 4層に分ける。WAF(関数を起動しない)→ Proxy(粗い網)→ Route Handler(精密な網+ボット検証)→ プロバイダ予算(最後の砦)。
  4. 複合キーを1本に連結しない。粗い網と細かい網を独立に持ち、AND で通す。
  5. 一貫性はデータごとに選ぶ。レート制限は結果整合性で正しく、課金は強い一貫性が要る。判断基準は「すり抜け1回の損失額」。
  6. 失敗の方向を非対称に設計する。可用性のための機構(レート制限)はオープンに、安全のための機構(ボット検証)はクローズに倒す。

そして最も重要なのは、これらすべてが「あなたのコードが正しく動く限り」という条件付きの防御だと自覚することです。デプロイ事故、環境変数の消失、依存サービスの障害——どれも起きます。だからコードの外側、プロバイダ側の予算上限というコードでは越えられない天井を必ず置いてください。それが、朝起きたときに請求書ではなくアラートを受け取るための、最後の設計判断です。

Sıkça sorulan sorular

ログイン必須にすれば、この問題は解決しますか?
軽減はしますが解決はしません。無料サインアップが可能なら、攻撃者はアカウントを量産して同じことをします(クレデンシャルスタッフィングや使い捨てメールも同様)。認証は『識別子の質』を上げる手段であって、消費量の上限そのものではありません。ログインの有無に関わらず、識別子ごとの上限・プロバイダ側の予算上限・異常検知の3点は必要です。ログインを課す本当の効果は、匿名IPよりも安定した識別子(ユーザーID)でレート制限をかけられることにあります。
Vercel KV はもう使えないのですか?
はい。Vercel KV は製品として提供終了し、既存ストアは2024年12月に Upstash Redis へ自動移行されました。現在は Vercel Marketplace から Upstash for Redis 等を導入し、認証情報がプロジェクトの環境変数に自動注入される形になります。コード側は @vercel/kv ではなく @upstash/redis と @upstash/ratelimit を使うのが2026年8月時点の標準です。
レート制限は middleware.ts と Route Handler のどちらに置くべきですか?
保護対象が1本の高単価エンドポイント(/api/chat)だけなら、Route Handler に置くほうが素直です。Next.js 16 では middleware.ts は非推奨(proxy.ts へ改名)で、公式も『最後の手段として使うことを推奨』と明記しています。proxy に置く価値があるのは、複数ルートを横断して同じ入口ポリシーを効かせたい場合や、本文を読む前に落として関数の実行時間を削りたい場合です。なお、いずれの層で落としても proxy/関数の呼び出し自体は課金対象なので、ボリューム型は WAF 側で落とすのが正解です。
Cloudflare Turnstile と Vercel BotID はどちらを選ぶべきですか?
Vercel 上で完結させたいなら BotID、Cloudflare を既に使っている・自前でトークン検証を握りたいなら Turnstile です。BotID の Basic は全プラン無料で、Deep Analysis(Kasada)は Pro で checkBotId() 呼び出し1000回あたり1ドル。Turnstile は無料枠が広く、siteverify を自分で叩くため検証ロジック(action/hostname の照合、冪等キー)を完全に制御できます。どちらも『ボット判定は確率的で、突破されうる』前提は同じなので、レート制限を外してよい理由にはなりません。
結果整合性のレート制限だと、上限を超えて通ってしまいませんか?
通ります。そして多くの場合それで正しい設計です。Upstash Redis の単一リージョン構成なら実質的にアトミックですが、マルチリージョン読み取りレプリカや Vercel WAF のレート制限(カウンタはリージョンごと)では、グローバルの合計が設定値を超えることがあります。判断基準は『すり抜け1回の損失額』です。推論1回分(数円〜数十円)なら許容し、課金・残高・在庫のように1回のすり抜けが金銭損失に直結するものだけ、強い一貫性のストアに置いてください。

Kaynaklar

友田

友田 陽大

Bakanlık (METI) Ödülü kazanan bir ürünün geliştiricisi. TypeScript + Python + AWS ile SaaS, endüstriyel dijital dönüşüm ve üretime hazır üretken yapay zekâyı (RAG) gereksinimlerden altyapıya ve işletmeye kadar tek başıma uçtan uca sunuyorum.

I can take on the implementation from this article as an engagement

Abuse and runaway-cost defense for generative AI endpoints — design, implementation, and monitoring

I implement the layered defense that lets you ship a login-free AI chat or a public LLM feature without breaking your invoice: volumetric control at the WAF, two-tier rate limiting that keeps the network and session identifiers independent, bot exclusion via Turnstile or BotID, a hard budget ceiling on the provider side, and monitoring that watches spend rather than error rate. Having run an unattended, login-free generative AI voice concierge in production — where you cannot control who walks up and talks to it — I design the consumption boundary first, so the thing stays up, stays cheap, and stays safe.

Hem proje bazlı işler hem de teknik danışmanlık için uygunum. 30 dakikalık ücretsiz bir görüşmeyle başlayın.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Bunlar da ilginizi çekebilir