From 67da1b30d43803f8c7f3985df4f6cc3e678fc3ae Mon Sep 17 00:00:00 2001 From: dwebxr Date: Thu, 24 Sep 2026 12:49:03 +0900 Subject: [PATCH] =?UTF-8?q?refactor(net):=20IP=20limiter=20=E3=81=AE?= =?UTF-8?q?=E9=8D=B5=E3=81=AE=E4=BD=9C=E3=82=8A=E6=96=B9=E3=82=92=E6=88=A6?= =?UTF-8?q?=E7=95=A5=E3=81=94=E3=81=A8=E3=81=AE=20wrapper=20=E3=81=AB?= =?UTF-8?q?=E5=8C=85=E3=81=BF=20body=20reader=20=E3=81=AE=E5=B7=AE?= =?UTF-8?q?=E3=82=92=E5=9B=BA=E5=AE=9A=20(R6b=E3=83=BB=E6=8E=9F=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 6 回レビュー Phase 6 の R6b (C15)。lib/net/clientRateLimit.ts に既存の 2 戦略 (hashIpBucket + checkIpRateLimit / anonymizeIp + checkReadRateLimit) をそのまま包む 関数を置き、非 money の 10 route (+ 有料 shops 検索の admission limiter) の呼び出しを 差し替える。鍵・窓・上限・429 応答・評価順は不変 (route ごとに実 relayGuards/ipHash で固定)。 body reader は 4 つとも readJsonBodyCapped と挙動が違う (BOM・不正 UTF-8・cap 境界・ cancel) ため置換せず、差を characterization test で固定した (統一は B-R6f)。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- app/api/auth/siwe/nonce/route.ts | 5 +- app/api/auth/siwe/verify/route.ts | 5 +- app/api/directory/_shared.ts | 10 +- app/api/handle/[handle]/route.ts | 7 +- app/api/license/products/[id]/route.ts | 5 +- app/api/license/verify/route.ts | 5 +- app/api/push/subscribe/route.ts | 12 +- app/api/push/test/route.ts | 11 +- app/api/shops/_shared.ts | 12 +- app/api/tip-messages/route.ts | 7 +- lib/net/clientRateLimit.ts | 40 ++ .../api/body-reader-characterization.test.ts | 435 ++++++++++++++++++ .../app/api/limiter-strategy-pinning.test.ts | 345 ++++++++++++++ 13 files changed, 845 insertions(+), 54 deletions(-) create mode 100644 lib/net/clientRateLimit.ts create mode 100644 tests/app/api/body-reader-characterization.test.ts create mode 100644 tests/app/api/limiter-strategy-pinning.test.ts diff --git a/app/api/auth/siwe/nonce/route.ts b/app/api/auth/siwe/nonce/route.ts index 3f93a24c..8216f901 100644 --- a/app/api/auth/siwe/nonce/route.ts +++ b/app/api/auth/siwe/nonce/route.ts @@ -6,8 +6,7 @@ import { rejectSiweCsrf } from '../_csrf'; import { isKvConfigured, kvSet } from '@/lib/kv'; import { nonceKey, NONCE_TTL_SEC, newSiweNonce } from '@/lib/siwe'; import { logger } from '@/lib/logger'; -import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; -import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { checkClientIpBucketRateLimit } from '@/lib/net/clientRateLimit'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -21,7 +20,7 @@ export async function POST(req: Request): Promise { { status: 503 }, ); } - if (!(await checkIpRateLimit('siwe-nonce', hashIpBucket(clientIp(req)), 60, 60))) { + if (!(await checkClientIpBucketRateLimit(req, 'siwe-nonce', 60, 60))) { return NextResponse.json( { error: 'rate_limited' }, { status: 429, headers: { 'Retry-After': '60' } }, diff --git a/app/api/auth/siwe/verify/route.ts b/app/api/auth/siwe/verify/route.ts index 5a7ab29d..d0b85ad4 100644 --- a/app/api/auth/siwe/verify/route.ts +++ b/app/api/auth/siwe/verify/route.ts @@ -12,8 +12,7 @@ import { readJsonBodyCapped } from '@/lib/httpBodyCap'; import { isKvConfigured, kvDel, kvSet } from '@/lib/kv'; import { chainObjectForId, isSupportedChainId, transportForChain } from '@/lib/chains'; import { logger } from '@/lib/logger'; -import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; -import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { checkClientIpBucketRateLimit } from '@/lib/net/clientRateLimit'; import { sessionCookieName, SESSION_TTL_SEC, @@ -38,7 +37,7 @@ export async function POST(req: Request): Promise { { status: 503 }, ); } - if (!(await checkIpRateLimit('siwe-verify', hashIpBucket(clientIp(req)), 30, 60))) { + if (!(await checkClientIpBucketRateLimit(req, 'siwe-verify', 30, 60))) { return NextResponse.json( { error: 'rate_limited' }, { status: 429, headers: { 'Retry-After': '60' } }, diff --git a/app/api/directory/_shared.ts b/app/api/directory/_shared.ts index 042c1e42..710fb1bc 100644 --- a/app/api/directory/_shared.ts +++ b/app/api/directory/_shared.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server'; import { env } from '@/lib/env'; -import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; -import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { checkClientIpBucketRateLimit } from '@/lib/net/clientRateLimit'; export const DIRECTORY_CACHE_CONTROL = 'public, s-maxage=60, stale-while-revalidate=120'; @@ -22,12 +21,7 @@ export async function guardFreeDirectoryApi( ): Promise { if (!env.enableWeb3Directory) return directoryError('not_found', 404); if ( - !(await checkIpRateLimit( - 'directory', - hashIpBucket(clientIp(req)), - 30, - 60, - )) + !(await checkClientIpBucketRateLimit(req, 'directory', 30, 60)) ) { return directoryError('rate_limited', 429, { 'Retry-After': '60' }); } diff --git a/app/api/handle/[handle]/route.ts b/app/api/handle/[handle]/route.ts index 3fe66a3e..c8c4ebdb 100644 --- a/app/api/handle/[handle]/route.ts +++ b/app/api/handle/[handle]/route.ts @@ -9,9 +9,7 @@ import { isKvConfigured } from '@/lib/kv'; import { requireSession } from '../../auth/siwe/_session'; import { validateHandle } from '@/lib/handle'; import { resolveHandle, releaseHandle } from '@/lib/handleStore'; -import { clientIp } from '@/lib/net/ipHash'; -import { checkReadRateLimit } from '@/lib/relay/relayGuards'; -import { anonymizeIp } from '@/lib/relay/relayRoute'; +import { checkClientIpPrefixRateLimit } from '@/lib/net/clientRateLimit'; export const runtime = 'nodejs'; export const maxDuration = 10; @@ -34,8 +32,7 @@ export async function GET( // IP 固定窓 (公開・無認証の予約可否 read)。@handle 空間の総当り列挙と、それによる KV read // 圧力が予約/公開の本体機能へ波及するのを入口で止める。dashboard の入力中チェック // (1 handle あたり数回) の遥か上の上限。 - const ipPrefix = anonymizeIp(clientIp(req) ?? ''); - if (!(await checkReadRateLimit(`handleavail:${ipPrefix}`, 60, 60))) { + if (!(await checkClientIpPrefixRateLimit(req, (ipPrefix) => `handleavail:${ipPrefix}`, 60, 60))) { return NextResponse.json( { ok: false, error: 'rate_limited' }, { status: 429 }, diff --git a/app/api/license/products/[id]/route.ts b/app/api/license/products/[id]/route.ts index fb71cb4d..baa0a95c 100644 --- a/app/api/license/products/[id]/route.ts +++ b/app/api/license/products/[id]/route.ts @@ -4,8 +4,7 @@ import { listHandlesForOwner } from '@/lib/handleStore'; import { licenseNftEnabled } from '@/lib/license/config'; import { licenseSummariesFor } from '@/lib/license/display'; import { sellerRoleFor } from '@/lib/license/sellerRole'; -import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; -import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { checkClientIpBucketRateLimit } from '@/lib/net/clientRateLimit'; import { storeProductPath } from '@/lib/storeProductLink'; import { getHostedProduct, isHostedId } from '@/lib/x402/hostedStore'; @@ -20,7 +19,7 @@ export async function GET(request: Request, context: { params: Promise<{ id: str const { id } = await context.params; // 不正 ID は rate limit の KV を含む全 IO より前に拒否する。 if (!isHostedId(id)) return error('invalid_input', 400); - if (!await checkIpRateLimit('license-products', hashIpBucket(clientIp(request)), 30, 60)) { + if (!await checkClientIpBucketRateLimit(request, 'license-products', 30, 60)) { const response = error('rate_limited', 429); response.headers.set('Retry-After', '60'); return response; diff --git a/app/api/license/verify/route.ts b/app/api/license/verify/route.ts index a2a814f4..3f69952d 100644 --- a/app/api/license/verify/route.ts +++ b/app/api/license/verify/route.ts @@ -4,8 +4,7 @@ import { kvGet, kvSet } from '@/lib/kv'; import { licenseNftEnabled } from '@/lib/license/config'; import { resolveLicenseRights, type LicenseRights } from '@/lib/license/rights'; import { acquireLicenseVerifyBudget, releaseLicenseVerifyBudget } from '@/lib/license/verifyBudget'; -import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; -import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { checkClientIpBucketRateLimit } from '@/lib/net/clientRateLimit'; import { getHostedProduct, isHostedId } from '@/lib/x402/hostedStore'; import { readStoreOwnership } from '@/lib/x402/storeEntitlement'; @@ -26,7 +25,7 @@ export async function GET(request: Request): Promise { const product = await getHostedProduct(productId); if (product === 'storage') return respond({ error: 'storage_unavailable' }, 503); if (!product || product.id !== productId || product.productKind !== 'license' || !product.license) return respond({ error: 'not_found' }, 404); - if (!await checkIpRateLimit('license-verify', hashIpBucket(clientIp(request)), 30, 60)) { + if (!await checkClientIpBucketRateLimit(request, 'license-verify', 30, 60)) { const response = respond({ error: 'rate_limited' }, 429); response.headers.set('Retry-After', '60'); return response; } diff --git a/app/api/push/subscribe/route.ts b/app/api/push/subscribe/route.ts index 24334e6e..91971dc9 100644 --- a/app/api/push/subscribe/route.ts +++ b/app/api/push/subscribe/route.ts @@ -2,9 +2,8 @@ import { NextResponse } from 'next/server'; import { requireSession } from '@/app/api/auth/siwe/_session'; import { env } from '@/lib/env'; import { isAllowedPushEndpoint } from '@/lib/push/endpoints'; -import { checkReadRateLimit } from '@/lib/relay/relayGuards'; -import { clientIp } from '@/lib/net/ipHash'; -import { MAX_BODY_BYTES, anonymizeIp } from '@/lib/relay/relayRoute'; +import { checkClientIpPrefixRateLimit } from '@/lib/net/clientRateLimit'; +import { MAX_BODY_BYTES } from '@/lib/relay/relayRoute'; import { listPushSubscriptions, removePushSubscription, @@ -126,11 +125,8 @@ async function rateLimited( wallet: string, ): Promise { try { - const ipPrefix = anonymizeIp( - clientIp(req) ?? '', - ); - const key = `pushsub:${wallet.toLowerCase()}:${ipPrefix}`; - if (!(await checkReadRateLimit(key, 20, 60))) { + const keyFor = (ipPrefix: string) => `pushsub:${wallet.toLowerCase()}:${ipPrefix}`; + if (!(await checkClientIpPrefixRateLimit(req, keyFor, 20, 60))) { return NextResponse.json({ ok: false, error: 'rate_limited' }, { status: 429 }); } } catch { diff --git a/app/api/push/test/route.ts b/app/api/push/test/route.ts index 55d19ad3..5a69ad64 100644 --- a/app/api/push/test/route.ts +++ b/app/api/push/test/route.ts @@ -5,9 +5,7 @@ import { NextResponse } from 'next/server'; import { requireSession } from '@/app/api/auth/siwe/_session'; import { env } from '@/lib/env'; -import { checkReadRateLimit } from '@/lib/relay/relayGuards'; -import { clientIp } from '@/lib/net/ipHash'; -import { anonymizeIp } from '@/lib/relay/relayRoute'; +import { checkClientIpPrefixRateLimit } from '@/lib/net/clientRateLimit'; import { sendPushToWallet } from '@/lib/push/server'; export const runtime = 'nodejs'; @@ -22,11 +20,8 @@ export async function POST(req: Request): Promise { if (!session.ok) return session.response; try { - const ipPrefix = anonymizeIp( - clientIp(req) ?? '', - ); - const key = `pushtest:${session.address.toLowerCase()}:${ipPrefix}`; - if (!(await checkReadRateLimit(key, 1, 60))) { + const keyFor = (ipPrefix: string) => `pushtest:${session.address.toLowerCase()}:${ipPrefix}`; + if (!(await checkClientIpPrefixRateLimit(req, keyFor, 1, 60))) { return NextResponse.json({ ok: false, error: 'rate_limited' }, { status: 429 }); } } catch { diff --git a/app/api/shops/_shared.ts b/app/api/shops/_shared.ts index 6bb63d5a..90d53fef 100644 --- a/app/api/shops/_shared.ts +++ b/app/api/shops/_shared.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server'; -import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; -import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { checkClientIpBucketRateLimit } from '@/lib/net/clientRateLimit'; import { shopsApiEnabled } from '@/lib/shops/flags'; export const SHOPS_CACHE_CONTROL = @@ -22,7 +21,7 @@ export async function guardFreeShopsApi( ): Promise { if (!shopsApiEnabled()) return shopsError('not_found', 404); if ( - !(await checkIpRateLimit('shops', hashIpBucket(clientIp(req)), 30, 60)) + !(await checkClientIpBucketRateLimit(req, 'shops', 30, 60)) ) { return shopsError('rate_limited', 429, { 'Retry-After': '60' }); } @@ -34,12 +33,7 @@ export async function guardPaidShopsApi( ): Promise { if (!shopsApiEnabled()) return shopsError('not_found', 404); if ( - !(await checkIpRateLimit( - 'shops-paid', - hashIpBucket(clientIp(req)), - 10, - 60, - )) + !(await checkClientIpBucketRateLimit(req, 'shops-paid', 10, 60)) ) { return shopsError('rate_limited', 429, { 'Retry-After': '60' }); } diff --git a/app/api/tip-messages/route.ts b/app/api/tip-messages/route.ts index 7d4cd28b..02cc55e3 100644 --- a/app/api/tip-messages/route.ts +++ b/app/api/tip-messages/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server'; import { requireSession } from '@/app/api/auth/siwe/_session'; import { env } from '@/lib/env'; -import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; -import { checkIpRateLimit } from '@/lib/relay/relayGuards'; +import { checkClientIpBucketRateLimit } from '@/lib/net/clientRateLimit'; import { deleteTipMessages, listTipMessages, @@ -33,9 +32,9 @@ function privateResponse(response: NextResponse): NextResponse { async function rateLimitResponse(req: Request): Promise { let allowed = true; try { - allowed = await checkIpRateLimit( + allowed = await checkClientIpBucketRateLimit( + req, 'tip-messages', - hashIpBucket(clientIp(req)), RATE_LIMIT_MAX, RATE_LIMIT_WINDOW_SEC, ); diff --git a/lib/net/clientRateLimit.ts b/lib/net/clientRateLimit.ts new file mode 100644 index 00000000..c61e466d --- /dev/null +++ b/lib/net/clientRateLimit.ts @@ -0,0 +1,40 @@ +import 'server-only'; + +import { clientIp, hashIpBucket } from '@/lib/net/ipHash'; +import { checkIpRateLimit, checkReadRateLimit } from '@/lib/relay/relayGuards'; +import { anonymizeIp } from '@/lib/relay/relayRoute'; + +// 利用者 IP の limiter を「どの鍵戦略か」が名前で分かる形にした薄い wrapper。 +// 呼び出しごとの現行の鍵・窓・名前空間・IP 不明時の扱いをそのまま保つ (R6b)。 +// 戦略を寄せる (鍵を変える) と本番のカウンタがリセットされるので、統一は B-R6 で別に判断する。 +// 鍵と応答は tests/app/api/limiter-strategy-pinning.test.ts が KV の境界で固定している。 +// IP の解決と HMAC (と警告 flag) は lib/net/ipHash だけが持つ。ここでは複製しない。 + +/** + * ip-bucket 戦略: 鍵 `iprl:v1::`、INCR の初回 TTL = 窓。 + * IP 不明・IP_HASH_SECRET 欠落は KV に触れず許可する (共有 bucket に寄せない)。 + * `checkIpRateLimit(scope, hashIpBucket(clientIp(req)), max, windowSec)` と同じ呼び出し。 + */ +export function checkClientIpBucketRateLimit( + req: Request, + scope: string, + max: number, + windowSec: number, +): Promise { + return checkIpRateLimit(scope, hashIpBucket(clientIp(req)), max, windowSec); +} + +/** + * ip-prefix 戦略: 鍵 `rl:read::` (IPv4 /24・IPv6 /64 の生 prefix)、 + * 時計に揃えた固定窓。IP 不明は共有の 'unknown' bucket に数え、IP_HASH_SECRET は使わない。 + * `checkReadRateLimit(keyFor(anonymizeIp(clientIp(req) ?? '')), max, windowSec)` と同じ呼び出し。 + */ +export function checkClientIpPrefixRateLimit( + req: Request, + keyFor: (ipPrefix: string) => string, + max: number, + windowSec: number, +): Promise { + const ipPrefix = anonymizeIp(clientIp(req) ?? ''); + return checkReadRateLimit(keyFor(ipPrefix), max, windowSec); +} diff --git a/tests/app/api/body-reader-characterization.test.ts b/tests/app/api/body-reader-characterization.test.ts new file mode 100644 index 00000000..cb82db4f --- /dev/null +++ b/tests/app/api/body-reader-characterization.test.ts @@ -0,0 +1,435 @@ +// @vitest-environment node +// R6b: 手書きの JSON body reader 4 本と lib/httpBodyCap の readJsonBodyCapped の差を固定する。 +// 置換の前提 (「観測できる挙動が同一」) を確かめるための characterization で、現行の挙動を +// そのまま期待値にしている (直すべき挙動かどうかは B-R6 で判断する)。 +// +// 対象: +// - push/subscribe の readJsonBody (req.text() → 復号後の再エンコード長で cap → JSON.parse) +// - register/claim の inline reader (同じ手順) +// - relay/jpyc/status の readBody (同じ手順・失敗はすべて invalid_payload) +// - lib/agent/purchasesHttp の purchasesBody (逐次読みの cap・Buffer の寛容な復号・content-type 必須) +// 参照: readJsonBodyCapped (逐次読みの cap は生 byte・fatal な UTF-8 復号・content-type を見ない)。 +// +// 判明した差 (いずれも置換すると応答が変わる): +// 1. BOM: req.text() は BOM を除いた文字列の長さで cap を測るので、生 byte が cap+3 でも通る。 +// readJsonBodyCapped は生 byte で測るので too_large。purchasesBody は BOM を残して JSON.parse が失敗する。 +// 2. 不正な UTF-8: req.text() と Buffer は U+FFFD に置き換えて受理する。readJsonBodyCapped は invalid_json。 +// 3. 不正な UTF-8 が cap 付近: 置換文字 (3 byte) で再エンコード長が伸び、生 byte が cap 以下でも 413。 +// readJsonBodyCapped は invalid_json (400)。purchasesBody は生 byte で測るので受理する。 +// 4. 早期 cancel: req.text() は cap を超えても最後まで読む。readJsonBodyCapped は超過時点で cancel する。 +// 5. content-type: purchasesBody だけが application/json を要求する (readJsonBodyCapped は見ない)。 +// 同一だった点: 上限ちょうど/+1 (素の ASCII)・空 body・body なし・読み取り中の stream エラー・ +// chunk 境界で分かれた多 byte 文字。 +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + claim: vi.fn(), + upsert: vi.fn(), +})); + +vi.mock('@/lib/env', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + env: { + ...actual.env, + enablePushNotify: true, + pushVapidPublicKey: 'test-public-key', + enableRegisterFee: true, + enableJpycEip3009: true, + }, + }; +}); +vi.mock('@/lib/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +vi.mock('@/app/api/auth/siwe/_session', () => ({ + requireSession: async () => ({ ok: true, address: '0x52d4901142e2B5680027da5EB47C86CB02a3cA81' }), + readSession: async () => ({ status: 'missing' }), +})); +vi.mock('@/lib/relay/relayGuards', () => ({ + checkReadRateLimit: vi.fn(async () => true), + checkIpRateLimit: vi.fn(async () => true), + readIdempotency: vi.fn(async () => ({ state: 'missing' })), +})); +vi.mock('@/lib/push/store', () => ({ + listPushSubscriptions: vi.fn(), + removePushSubscription: vi.fn(), + upsertPushSubscription: h.upsert, +})); +vi.mock('@/lib/registerFeeClaim', () => ({ + claimRegisterFeePayment: h.claim, +})); +vi.mock('@/lib/relay/relayProvider', () => ({ + PROVIDER: 'self-host', + SUPPORTED_CHAINS: { 80002: {} }, + jpycAddressFor: () => '0x2222222222222222222222222222222222222222', + readAuthorizationUsed: vi.fn(async () => false), + findAuthorizationUsedTransactionHash: vi.fn(async () => null), +})); +vi.mock('@/lib/relay/forwarderConfig', () => ({ + jpycForwarderFor: () => null, +})); +vi.mock('@/lib/relay/forwarderSettleService', () => ({ + feeReceiverFor: () => '0x3333333333333333333333333333333333333333', +})); + +import { POST as pushSubscribePost } from '@/app/api/push/subscribe/route'; +import { POST as registerClaimPost } from '@/app/api/register/claim/route'; +import { POST as relayStatusPost } from '@/app/api/relay/jpyc/status/route'; +import { purchasesBody } from '@/lib/agent/purchasesHttp'; +import { readJsonBodyCapped } from '@/lib/httpBodyCap'; +import { MAX_BODY_BYTES } from '@/lib/relay/relayRoute'; + +const enc = new TextEncoder(); +const BOM = [0xef, 0xbb, 0xbf]; +const PURCHASES_CAP = 2048; + +type Outcome = 'accepted' | 'too_large' | 'invalid'; + +type BodySpec = { + chunks: Uint8Array[]; + contentType?: string | null; + streamError?: boolean; + noBody?: boolean; +}; + +// base の JSON に "pad" 欄を足して、生 byte 長をちょうど size にする。bad 個の 0xFF (不正な +// UTF-8) を pad 文字列の中に置く (JSON としては文字列内なので、寛容な復号なら U+FFFD になる)。 +function padded(base: Record, size: number, opts: { bom?: boolean; bad?: number } = {}): Uint8Array { + const head = enc.encode(`${JSON.stringify(base).slice(0, -1)},"pad":"`); + const tail = enc.encode('"}'); + const bom = opts.bom ? BOM : []; + const bad = opts.bad ?? 0; + const fill = size - bom.length - head.length - tail.length - bad; + if (fill < 0) throw new Error('size too small for base'); + const bytes = Uint8Array.from([ + ...bom, + ...head, + ...new Array(bad).fill(0xff), + ...enc.encode('x'.repeat(fill)), + ...tail, + ]); + expect(bytes.byteLength).toBe(size); + return bytes; +} + +// 有効な JSON の途中で多 byte 文字 (あ = E3 81 82) を chunk 境界で割る。 +function splitMultibyte(base: Record): Uint8Array[] { + const bytes = enc.encode(JSON.stringify({ ...base, pad: 'あ' })); + const at = bytes.indexOf(0xe3) + 1; + return [bytes.slice(0, at), bytes.slice(at)]; +} + +function streamOf(chunks: Uint8Array[], streamError = false): ReadableStream { + return new ReadableStream({ + start(controller) { + if (streamError) { + controller.enqueue(chunks[0]); + return; + } + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + pull(controller) { + // 1 chunk 目を渡した後で接続断を模す。 + if (streamError) controller.error(new TypeError('terminated')); + }, + }); +} + +function requestFor(url: string, spec: BodySpec): Request { + const headers = new Headers(); + const contentType = spec.contentType === undefined ? 'application/json' : spec.contentType; + if (contentType !== null) headers.set('content-type', contentType); + headers.set('x-forwarded-for', '203.0.113.55'); + if (spec.noBody) return new Request(url, { method: 'POST', headers }); + return new Request(url, { + method: 'POST', + headers, + body: streamOf(spec.chunks, spec.streamError), + duplex: 'half', + } as RequestInit); +} + +function sourceFor(spec: BodySpec): { body: ReadableStream | null } { + if (spec.noBody) return { body: null }; + return { body: streamOf(spec.chunks, spec.streamError) }; +} + +const SUBSCRIPTION = { + endpoint: 'https://fcm.googleapis.com/sub/1', + keys: { p256dh: 'A'.repeat(87), auth: 'B'.repeat(22) }, +}; +const PUSH_BASE = { subscription: SUBSCRIPTION, locale: 'ja' }; +const REGISTER_BASE = { + chainId: 137, + tokenAddress: `0x${'a'.repeat(40)}`, + merchant: `0x${'b'.repeat(40)}`, + saleAmount: '100', + merchantTxHash: `0x${'1'.repeat(64)}`, + feeTxHash: `0x${'2'.repeat(64)}`, +}; +const STATUS_BASE = { + lookup: 'nonce', + chainId: 80002, + from: '0x1111111111111111111111111111111111111111', + nonce: `0x${'1'.repeat(64)}`, +}; +const PURCHASES_BASE = { address: '0x1111111111111111111111111111111111111111' }; + +type RouteReader = { + name: string; + cap: number; + base: Record; + run: (spec: BodySpec) => Promise<{ status: number; body: string }>; + responses: Record; +}; + +const asResult = async (res: Response) => ({ status: res.status, body: await res.text() }); + +const routeReaders: RouteReader[] = [ + { + name: 'push/subscribe POST', + cap: MAX_BODY_BYTES, + base: PUSH_BASE, + run: async (spec) => asResult(await pushSubscribePost(requestFor('http://localhost/api/push/subscribe', spec))), + responses: { + accepted: { status: 200, body: '{"ok":true,"count":1}' }, + too_large: { status: 413, body: '{"ok":false,"error":"payload_too_large"}' }, + invalid: { status: 400, body: '{"ok":false,"error":"invalid_json"}' }, + }, + }, + { + name: 'register/claim POST', + cap: MAX_BODY_BYTES, + base: REGISTER_BASE, + run: async (spec) => asResult(await registerClaimPost(requestFor('http://localhost/api/register/claim', spec))), + responses: { + accepted: { status: 200, body: '{"ok":true,"status":"claimed"}' }, + too_large: { status: 413, body: '{"ok":false,"error":"payload_too_large"}' }, + invalid: { status: 400, body: '{"ok":false,"error":"invalid_json"}' }, + }, + }, + { + name: 'relay/jpyc/status POST', + cap: MAX_BODY_BYTES, + base: STATUS_BASE, + run: async (spec) => asResult(await relayStatusPost(requestFor('http://localhost/api/relay/jpyc/status', spec))), + responses: { + accepted: { status: 200, body: '{"ok":true,"state":"unused"}' }, + // readBody は失敗理由を区別せず null → 400 invalid_payload。 + too_large: { status: 400, body: '{"ok":false,"error":"invalid_payload"}' }, + invalid: { status: 400, body: '{"ok":false,"error":"invalid_payload"}' }, + }, + }, +]; + +type CaseDef = { + name: string; + spec: (base: Record, cap: number) => BodySpec; + // 手書き reader (req.text() 系) の結果 + text: Outcome; + // purchasesBody の結果 + purchases: 'accepted' | 'null'; + // readJsonBodyCapped の結果 + capped: Outcome; +}; + +const CASES: CaseDef[] = [ + { + name: 'ASCII の生 byte が上限ちょうど', + spec: (base, cap) => ({ chunks: [padded(base, cap)] }), + text: 'accepted', purchases: 'accepted', capped: 'accepted', + }, + { + name: 'ASCII の生 byte が上限 +1', + spec: (base, cap) => ({ chunks: [padded(base, cap + 1)] }), + text: 'too_large', purchases: 'null', capped: 'too_large', + }, + { + name: '上限 +1 を 1 byte ずつの chunk で送る', + spec: (base, cap) => ({ chunks: [...padded(base, cap + 1)].map((b) => Uint8Array.of(b)) }), + text: 'too_large', purchases: 'null', capped: 'too_large', + }, + { + name: '多 byte 文字を chunk 境界で割った有効な JSON', + spec: (base) => ({ chunks: splitMultibyte(base) }), + text: 'accepted', purchases: 'accepted', capped: 'accepted', + }, + { + name: '小さい BOM 付き JSON', + spec: (base) => ({ chunks: [Uint8Array.from([...BOM, ...enc.encode(JSON.stringify(base))])] }), + // purchasesBody は Buffer#toString が BOM を残し JSON.parse が失敗する。 + text: 'accepted', purchases: 'null', capped: 'accepted', + }, + { + name: 'BOM 付きで生 byte が上限 +3 (BOM を除くと上限ちょうど)', + spec: (base, cap) => ({ chunks: [padded(base, cap + 3, { bom: true })] }), + // 差 1: req.text() は BOM を除いた長さで測る。 + text: 'accepted', purchases: 'null', capped: 'too_large', + }, + { + name: '文字列内に不正な UTF-8 (0xFF) がある小さい JSON', + spec: (base) => ({ chunks: [padded(base, 600, { bad: 1 })] }), + // 差 2: 寛容な復号は U+FFFD で受理、fatal な復号は invalid_json。 + text: 'accepted', purchases: 'accepted', capped: 'invalid', + }, + { + name: '不正な UTF-8 を 10 byte 含み生 byte が上限ちょうど', + spec: (base, cap) => ({ chunks: [padded(base, cap, { bad: 10 })] }), + // 差 3: U+FFFD (3 byte) で再エンコード長が cap + 20 になり 413。purchasesBody は生 byte で測る。 + text: 'too_large', purchases: 'accepted', capped: 'invalid', + }, + { + name: 'overlong / surrogate の UTF-8 (ED A0 80)', + spec: (base) => { + const bytes = enc.encode(JSON.stringify({ ...base, pad: 'zzz' })); + const at = bytes.indexOf(0x7a); + bytes.set([0xed, 0xa0, 0x80], at); + return { chunks: [bytes] }; + }, + text: 'accepted', purchases: 'accepted', capped: 'invalid', + }, + { + name: '空の stream (0 byte)', + spec: () => ({ chunks: [] }), + text: 'invalid', purchases: 'null', capped: 'invalid', + }, + { + name: 'body なし', + spec: () => ({ chunks: [], noBody: true }), + text: 'invalid', purchases: 'null', capped: 'invalid', + }, + { + name: '1 chunk 目の後に stream エラー', + spec: (base) => ({ chunks: [enc.encode(JSON.stringify(base).slice(0, 10))], streamError: true }), + text: 'invalid', purchases: 'null', capped: 'invalid', + }, + { + name: 'content-type が text/plain の有効な JSON', + spec: (base) => ({ chunks: [enc.encode(JSON.stringify(base))], contentType: 'text/plain' }), + // 差 5: purchasesBody だけが content-type を要求する。 + text: 'accepted', purchases: 'null', capped: 'accepted', + }, + { + name: 'content-type なしの有効な JSON', + spec: (base) => ({ chunks: [enc.encode(JSON.stringify(base))], contentType: null }), + text: 'accepted', purchases: 'null', capped: 'accepted', + }, +]; + +beforeEach(() => { + h.claim.mockReset().mockResolvedValue('claimed'); + h.upsert.mockReset().mockResolvedValue({ ok: true, value: [{ endpoint: SUBSCRIPTION.endpoint }] }); +}); + +describe.each(routeReaders)('$name の body reader', (reader) => { + it.each(CASES)('$name', async (c) => { + const result = await reader.run(c.spec(reader.base, reader.cap)); + expect(result).toEqual(reader.responses[c.text]); + }); +}); + +describe('purchasesBody (agent proof verify/unbind)', () => { + it.each(CASES)('$name', async (c) => { + const spec = c.spec(PURCHASES_BASE, PURCHASES_CAP); + const value = await purchasesBody(requestFor('http://localhost/api/agent/proof/verify', spec)); + if (c.purchases === 'null') { + expect(value).toBeNull(); + } else { + expect(value).toMatchObject(PURCHASES_BASE); + } + }); + + it('JSON が object 以外 (null・配列・数値) なら null', async () => { + for (const text of ['null', '[]', '[{"a":1}]', '1', '"s"']) { + expect(await purchasesBody(requestFor('http://localhost/x', { chunks: [enc.encode(text)] }))).toBeNull(); + } + }); + + it('content-type は ; 以降を無視し大文字小文字を区別しない', async () => { + const value = await purchasesBody(requestFor('http://localhost/x', { + chunks: [enc.encode('{"a":1}')], + contentType: ' Application/JSON ; charset=utf-8', + })); + expect(value).toEqual({ a: 1 }); + }); +}); + +describe('readJsonBodyCapped (参照)', () => { + const expectCapped = (result: Awaited>, outcome: Outcome) => { + if (outcome === 'accepted') expect(result.ok).toBe(true); + else expect(result).toEqual({ ok: false, reason: outcome === 'too_large' ? 'too_large' : 'invalid_json' }); + }; + + it.each(CASES)('cap 4096: $name', async (c) => { + expectCapped(await readJsonBodyCapped(sourceFor(c.spec(PUSH_BASE, MAX_BODY_BYTES)), MAX_BODY_BYTES), c.capped); + }); + + it.each(CASES)('cap 2048: $name', async (c) => { + expectCapped(await readJsonBodyCapped(sourceFor(c.spec(PURCHASES_BASE, PURCHASES_CAP)), PURCHASES_CAP), c.capped); + }); + + it('Request 経由でも同じ (body なしは invalid_json)', async () => { + expectCapped(await readJsonBodyCapped(requestFor('http://localhost/x', { chunks: [], noBody: true }), 10), 'invalid'); + }); +}); + +// 差 4: 上限超過後も読み続けるか。応答は同じでも、接続側の消費量が違う。 +describe('上限超過時の読み取り量', () => { + const CHUNK = 1024; + const TOTAL_CHUNKS = 32; + + function countingStream() { + const state = { pulls: 0, cancelled: 0 }; + const body = new ReadableStream({ + pull(controller) { + state.pulls += 1; + controller.enqueue(enc.encode('x'.repeat(CHUNK))); + if (state.pulls === TOTAL_CHUNKS) controller.close(); + }, + cancel() { + state.cancelled += 1; + }, + }, { highWaterMark: 0 }); + return { state, body }; + } + + function countingRequest(url: string) { + const { state, body } = countingStream(); + const req = new Request(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.55' }, + body, + duplex: 'half', + } as RequestInit); + return { state, req }; + } + + it.each([ + ['push/subscribe POST', (req: Request) => pushSubscribePost(req), 413], + ['register/claim POST', (req: Request) => registerClaimPost(req), 413], + ['relay/jpyc/status POST', (req: Request) => relayStatusPost(req), 400], + ] as const)('%s は req.text() で最後まで読み、cancel しない', async (_name, run, status) => { + const { state, req } = countingRequest('http://localhost/x'); + expect((await run(req)).status).toBe(status); + expect(state.pulls).toBe(TOTAL_CHUNKS); + expect(state.cancelled).toBe(0); + }); + + it('purchasesBody は上限超過の chunk で cancel する', async () => { + const { state, req } = countingRequest('http://localhost/x'); + expect(await purchasesBody(req)).toBeNull(); + expect(state.cancelled).toBe(1); + expect(state.pulls).toBeLessThan(TOTAL_CHUNKS); + expect(state.pulls).toBe(Math.floor(PURCHASES_CAP / CHUNK) + 1); + }); + + it('readJsonBodyCapped は上限超過の chunk で cancel する', async () => { + const { state, req } = countingRequest('http://localhost/x'); + expect(await readJsonBodyCapped(req, MAX_BODY_BYTES)).toEqual({ ok: false, reason: 'too_large' }); + expect(state.cancelled).toBe(1); + expect(state.pulls).toBe(Math.floor(MAX_BODY_BYTES / CHUNK) + 1); + }); +}); diff --git a/tests/app/api/limiter-strategy-pinning.test.ts b/tests/app/api/limiter-strategy-pinning.test.ts new file mode 100644 index 00000000..4dfb02de --- /dev/null +++ b/tests/app/api/limiter-strategy-pinning.test.ts @@ -0,0 +1,345 @@ +// @vitest-environment node +// R6b: route ごとの IP limiter の「鍵・窓・名前空間・IP 不明時の扱い」を KV の境界で固定する。 +// relayGuards / ipHash / relayRoute は本物を使い、@/lib/kv の INCR/EXPIRE だけを差し替える。 +// 呼び出し側を wrapper に寄せても、ここで見る KV 鍵と応答が 1 byte も変わらないことが条件 +// (鍵が変わると本番のカウンタがリセットされる = B-R6 の範囲)。 +// +// 現行の 2 戦略: +// ip-bucket: 鍵 `iprl:v1::`・INCR の初回 TTL = 窓 (初回起点の固定窓)・ +// IP 不明や IP_HASH_SECRET 欠落は KV に触れず通す。 +// ip-prefix: 鍵 `rl:read::`・時計に揃えた固定窓・ +// 初回だけ EXPIRE 窓×2・IP 不明は共有の 'unknown' bucket・secret は使わない。 +import { createHmac } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + kvConfigured: true, + limiterResult: { ok: true, value: 1 } as { ok: true; value: number } | { ok: false; reason: string }, + kvIncr: vi.fn(), + kvExpire: vi.fn(), +})); + +vi.mock('@/lib/kv', async (importOriginal) => ({ + ...(await importOriginal()), + isKvConfigured: () => h.kvConfigured, + kvIncr: h.kvIncr, + kvExpire: h.kvExpire, +})); +vi.mock('@/lib/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); +vi.mock('@/lib/env', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + env: { + ...actual.env, + enableTipMessage: true, + enableWeb3Directory: true, + enableShopsApi: true, + enableX402Facilitator: true, + enableOrderRelay: true, + enableAgentOrder: true, + enableHandles: true, + enablePushNotify: true, + pushVapidPublicKey: 'test-public-key', + }, + }; +}); +vi.mock('@/app/api/auth/siwe/_session', () => ({ + requireSession: async () => ({ ok: true, address: '0x52d4901142e2B5680027da5EB47C86CB02a3cA81' }), +})); +vi.mock('@/lib/license/config', () => ({ licenseNftEnabled: () => true })); +vi.mock('@/lib/x402/hostedStore', async (importOriginal) => ({ + ...(await importOriginal()), + getHostedProduct: async () => ({ + id: 'h_' + 'a'.repeat(32), + productKind: 'license', + owner: '0x1111111111111111111111111111111111111111', + license: { definitionHash: 'd', tokenChainId: 137, contract: '0x2222222222222222222222222222222222222222', tokenId: '1' }, + }), +})); +vi.mock('@/lib/handleStore', () => ({ + listHandlesForOwner: async () => null, + resolveHandle: async () => ({ ok: true, record: null }), + releaseHandle: async () => 'released', +})); +vi.mock('@/lib/tipMessages', () => ({ + listTipMessages: async () => [], + deleteTipMessages: async () => true, +})); +vi.mock('@/lib/push/store', () => ({ + listPushSubscriptions: async () => ({ ok: true, value: [] }), + upsertPushSubscription: async () => ({ ok: true, value: [] }), + removePushSubscription: async () => ({ ok: true, value: [] }), +})); +vi.mock('@/lib/push/server', () => ({ + sendPushToWallet: async () => ({ attempted: 0, sent: 0, removed: 0, failed: 0 }), +})); + +const SECRET = '0123456789abcdef0123456789abcdef'; +const WALLET = '0x52d4901142e2B5680027da5EB47C86CB02a3cA81'; +const PRODUCT_ID = 'h_' + 'a'.repeat(32); +// 2026-09-24T00:00:30Z: 60 秒窓の途中 (窓の境界で bucket がずれないことも別に見る)。 +const NOW = Date.UTC(2026, 8, 24, 0, 0, 30); +const digest = (network: string) => createHmac('sha256', SECRET).update(`ip:${network}`).digest('hex'); +const isLimiterKey = (key: unknown) => typeof key === 'string' && (key.startsWith('iprl:') || key.startsWith('rl:read:')); +const limiterIncrCalls = () => h.kvIncr.mock.calls.filter(([key]) => isLimiterKey(key)); +const limiterExpireCalls = () => h.kvExpire.mock.calls.filter(([key]) => isLimiterKey(key)); + +type Headers4 = Record; + +// 真の利用者 IP を Cloudflare 経由 (信頼できる接続元) で渡す。 +function trusted(ip: string): Headers4 { + return { 'x-vercel-forwarded-for': '172.71.0.1', 'cf-connecting-ip': ip }; +} + +function makeRequest(url: string, method: string, headers: Headers4, body?: string): Request { + return new Request(url, { + method, + headers: { 'content-type': 'application/json', ...headers }, + ...(body === undefined ? {} : { body }), + }); +} + +type Expected429 = { body: string; headers: Record }; + +type BucketRoute = { + name: string; + scope: string; + max: number; + windowSec: number; + call: (headers: Headers4) => Promise; + denied: Expected429; +}; + +const ctx = >(params: T) => ({ params: Promise.resolve(params) }); + +const bucketRoutes: BucketRoute[] = [ + { + name: 'SIWE nonce', scope: 'siwe-nonce', max: 60, windowSec: 60, + call: async (hd) => (await import('@/app/api/auth/siwe/nonce/route')).POST(makeRequest('https://test.local/api/auth/siwe/nonce', 'POST', hd)), + denied: { body: '{"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': null } }, + }, + { + name: 'SIWE verify', scope: 'siwe-verify', max: 30, windowSec: 60, + call: async (hd) => (await import('@/app/api/auth/siwe/verify/route')).POST(makeRequest('https://test.local/api/auth/siwe/verify', 'POST', hd, '{}')), + denied: { body: '{"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': null } }, + }, + { + name: 'license descriptor', scope: 'license-products', max: 30, windowSec: 60, + call: async (hd) => (await import('@/app/api/license/products/[id]/route')).GET( + makeRequest(`https://test.local/api/license/products/${PRODUCT_ID}`, 'GET', hd), ctx({ id: PRODUCT_ID })), + denied: { body: '{"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': 'no-store' } }, + }, + { + name: 'license verify', scope: 'license-verify', max: 30, windowSec: 60, + call: async (hd) => (await import('@/app/api/license/verify/route')).GET( + makeRequest(`https://test.local/api/license/verify?address=0x1111111111111111111111111111111111111111&product=${PRODUCT_ID}`, 'GET', hd)), + denied: { body: '{"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': 'no-store' } }, + }, + { + name: 'shops (free)', scope: 'shops', max: 30, windowSec: 60, + call: async (hd) => (await import('@/app/api/shops/_shared')).guardFreeShopsApi(makeRequest('https://test.local/api/shops', 'GET', hd)), + denied: { body: '{"ok":false,"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': null } }, + }, + { + name: 'shops (paid)', scope: 'shops-paid', max: 10, windowSec: 60, + call: async (hd) => (await import('@/app/api/shops/_shared')).guardPaidShopsApi(makeRequest('https://test.local/api/shops/find', 'GET', hd)), + denied: { body: '{"ok":false,"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': null } }, + }, + { + name: 'directory', scope: 'directory', max: 30, windowSec: 60, + call: async (hd) => (await import('@/app/api/directory/_shared')).guardFreeDirectoryApi(makeRequest('https://test.local/api/directory', 'GET', hd)), + denied: { body: '{"ok":false,"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': null } }, + }, + { + name: 'tip messages GET', scope: 'tip-messages', max: 30, windowSec: 60, + call: async (hd) => (await import('@/app/api/tip-messages/route')).GET(makeRequest('https://test.local/api/tip-messages', 'GET', hd)), + denied: { body: '{"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': 'private, no-store' } }, + }, + { + name: 'tip messages DELETE', scope: 'tip-messages', max: 30, windowSec: 60, + call: async (hd) => (await import('@/app/api/tip-messages/route')).DELETE(makeRequest('https://test.local/api/tip-messages', 'DELETE', hd)), + denied: { body: '{"error":"rate_limited"}', headers: { 'retry-after': '60', 'cache-control': 'private, no-store' } }, + }, +]; + +type PrefixRoute = { + name: string; + key: (prefix: string) => string; + max: number; + windowSec: number; + call: (headers: Headers4) => Promise; + deniedBody: string; +}; + +const prefixRoutes: PrefixRoute[] = [ + { + name: 'handle availability GET', key: (p) => `handleavail:${p}`, max: 60, windowSec: 60, + call: async (hd) => (await import('@/app/api/handle/[handle]/route')).GET( + makeRequest('https://test.local/api/handle/testshop123', 'GET', hd), ctx({ handle: 'testshop123' })), + deniedBody: '{"ok":false,"error":"rate_limited"}', + }, + { + name: 'push subscribe GET', key: (p) => `pushsub:${WALLET.toLowerCase()}:${p}`, max: 20, windowSec: 60, + call: async (hd) => (await import('@/app/api/push/subscribe/route')).GET(makeRequest('https://test.local/api/push/subscribe', 'GET', hd)), + deniedBody: '{"ok":false,"error":"rate_limited"}', + }, + { + name: 'push subscribe POST', key: (p) => `pushsub:${WALLET.toLowerCase()}:${p}`, max: 20, windowSec: 60, + call: async (hd) => (await import('@/app/api/push/subscribe/route')).POST(makeRequest('https://test.local/api/push/subscribe', 'POST', hd, '{}')), + deniedBody: '{"ok":false,"error":"rate_limited"}', + }, + { + name: 'push subscribe DELETE', key: (p) => `pushsub:${WALLET.toLowerCase()}:${p}`, max: 20, windowSec: 60, + call: async (hd) => (await import('@/app/api/push/subscribe/route')).DELETE(makeRequest('https://test.local/api/push/subscribe', 'DELETE', hd, '{}')), + deniedBody: '{"ok":false,"error":"rate_limited"}', + }, + { + name: 'push test POST', key: (p) => `pushtest:${WALLET.toLowerCase()}:${p}`, max: 1, windowSec: 60, + call: async (hd) => (await import('@/app/api/push/test/route')).POST(makeRequest('https://test.local/api/push/test', 'POST', hd)), + deniedBody: '{"ok":false,"error":"rate_limited"}', + }, +]; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + vi.stubEnv('IP_HASH_SECRET', SECRET); + h.kvConfigured = true; + h.limiterResult = { ok: true, value: 1 }; + h.kvIncr.mockReset().mockImplementation(async (key: string) => + isLimiterKey(key) ? h.limiterResult : { ok: false, reason: 'unconfigured' }); + h.kvExpire.mockReset().mockResolvedValue({ ok: true, value: 1 }); +}); +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe.each(bucketRoutes)('ip-bucket: $name', (route) => { + it.each([ + ['IPv4 (/32)', '203.0.113.9', '203.0.113.9'], + ['IPv4-mapped IPv6 (/32 に揃える)', '::ffff:203.0.113.9', '203.0.113.9'], + ['IPv6 (/64 にまとめる)', '2001:db8:1234:5678::1', '2001:db8:1234:5678::'], + ])('上限超過: %s の鍵・窓・429 応答', async (_label, ip, network) => { + h.limiterResult = { ok: true, value: route.max + 1 }; + const res = await route.call(trusted(ip)); + expect(limiterIncrCalls()).toEqual([[`iprl:v1:${route.scope}:${digest(network)}`, { initialTtlSec: route.windowSec }]]); + expect(limiterExpireCalls()).toEqual([]); + expect(res?.status).toBe(429); + expect(await res!.text()).toBe(route.denied.body); + for (const [name, value] of Object.entries(route.denied.headers)) { + expect(res!.headers.get(name)).toBe(value); + } + }); + + it('カウントが上限ちょうどなら通す', async () => { + h.limiterResult = { ok: true, value: route.max }; + const res = await route.call(trusted('203.0.113.9')); + expect(limiterIncrCalls()).toEqual([[`iprl:v1:${route.scope}:${digest('203.0.113.9')}`, { initialTtlSec: route.windowSec }]]); + expect(res?.status ?? 200).not.toBe(429); + }); + + it('信頼できない cf-connecting-ip は無視し、接続元 (XFF) の bucket を使う', async () => { + h.limiterResult = { ok: true, value: route.max + 1 }; + const res = await route.call({ 'x-forwarded-for': '198.51.100.7', 'cf-connecting-ip': '203.0.113.9' }); + expect(limiterIncrCalls()).toEqual([[`iprl:v1:${route.scope}:${digest('198.51.100.7')}`, { initialTtlSec: route.windowSec }]]); + expect(res?.status).toBe(429); + }); + + it('IP 不明なら KV に触れず通す (共有 bucket に寄せない)', async () => { + h.limiterResult = { ok: true, value: 10_000 }; + const res = await route.call({}); + expect(limiterIncrCalls()).toEqual([]); + expect(res?.status ?? 200).not.toBe(429); + }); + + it('IP_HASH_SECRET 欠落なら KV に触れず通す', async () => { + vi.stubEnv('IP_HASH_SECRET', ''); + h.limiterResult = { ok: true, value: 10_000 }; + const res = await route.call(trusted('203.0.113.9')); + expect(limiterIncrCalls()).toEqual([]); + expect(res?.status ?? 200).not.toBe(429); + }); + + it('INCR の失敗は通す (fail-open)', async () => { + h.limiterResult = { ok: false, reason: 'timeout' }; + const res = await route.call(trusted('203.0.113.9')); + expect(limiterIncrCalls()).toHaveLength(1); + expect(res?.status ?? 200).not.toBe(429); + }); +}); + +describe.each(prefixRoutes)('ip-prefix: $name', (route) => { + const bucket = Math.floor(NOW / (route.windowSec * 1000)); + + it.each([ + ['IPv4 (/24)', '203.0.113.9', '203.0.113.0/24'], + ['IPv4-mapped IPv6 (/24)', '::ffff:203.0.113.9', '203.0.113.0/24'], + ['IPv6 (/64)', '2001:db8:1234:5678::1', '2001:db8:1234:5678::/64'], + ])('上限超過: %s の鍵と 429 応答 (EXPIRE は初回だけ)', async (_label, ip, prefix) => { + h.limiterResult = { ok: true, value: route.max + 1 }; + const res = await route.call(trusted(ip)); + expect(limiterIncrCalls()).toEqual([[`rl:read:${route.key(prefix)}:${bucket}`]]); + expect(limiterExpireCalls()).toEqual(route.max + 1 === 1 ? [[`rl:read:${route.key(prefix)}:${bucket}`, route.windowSec * 2]] : []); + expect(res.status).toBe(429); + expect(await res.text()).toBe(route.deniedBody); + expect(res.headers.get('retry-after')).toBeNull(); + }); + + it('初回 (カウント 1) は窓 2 つ分の EXPIRE を付けて通す', async () => { + h.limiterResult = { ok: true, value: 1 }; + const res = await route.call(trusted('203.0.113.9')); + const key = `rl:read:${route.key('203.0.113.0/24')}:${bucket}`; + expect(limiterIncrCalls()).toEqual([[key]]); + expect(limiterExpireCalls()).toEqual([[key, route.windowSec * 2]]); + expect(res.status).not.toBe(429); + }); + + it('カウントが上限ちょうどなら通す', async () => { + h.limiterResult = { ok: true, value: route.max }; + const res = await route.call(trusted('203.0.113.9')); + expect(res.status).not.toBe(429); + }); + + it('窓は時計に揃う (次の分で bucket が変わる)', async () => { + vi.setSystemTime(Math.ceil(NOW / 60_000) * 60_000); + await route.call(trusted('203.0.113.9')); + expect(limiterIncrCalls()).toEqual([[`rl:read:${route.key('203.0.113.0/24')}:${bucket + 1}`]]); + }); + + it('IP 不明は共有の unknown bucket に数える', async () => { + h.limiterResult = { ok: true, value: route.max + 1 }; + const res = await route.call({}); + expect(limiterIncrCalls()).toEqual([[`rl:read:${route.key('unknown')}:${bucket}`]]); + expect(res.status).toBe(429); + }); + + it('信頼できない cf-connecting-ip は無視し、接続元 (XFF) の prefix を使う', async () => { + await route.call({ 'x-forwarded-for': '198.51.100.7', 'cf-connecting-ip': '203.0.113.9' }); + expect(limiterIncrCalls()).toEqual([[`rl:read:${route.key('198.51.100.0/24')}:${bucket}`]]); + }); + + it('IP_HASH_SECRET は使わない (欠落でも同じ鍵で数える)', async () => { + vi.stubEnv('IP_HASH_SECRET', ''); + await route.call(trusted('203.0.113.9')); + expect(limiterIncrCalls()).toEqual([[`rl:read:${route.key('203.0.113.0/24')}:${bucket}`]]); + }); + + it('KV 未設定なら KV に触れず通す', async () => { + h.kvConfigured = false; + const res = await route.call(trusted('203.0.113.9')); + expect(limiterIncrCalls()).toEqual([]); + expect(res.status).not.toBe(429); + }); + + it('INCR の失敗は通す (fail-open)', async () => { + h.limiterResult = { ok: false, reason: 'network_error' }; + const res = await route.call(trusted('203.0.113.9')); + expect(limiterIncrCalls()).toHaveLength(1); + expect(limiterExpireCalls()).toEqual([]); + expect(res.status).not.toBe(429); + }); +});