Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions app/api/auth/siwe/nonce/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,7 +20,7 @@ export async function POST(req: Request): Promise<NextResponse> {
{ 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' } },
Expand Down
5 changes: 2 additions & 3 deletions app/api/auth/siwe/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,7 +37,7 @@ export async function POST(req: Request): Promise<NextResponse> {
{ 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' } },
Expand Down
10 changes: 2 additions & 8 deletions app/api/directory/_shared.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,12 +21,7 @@ export async function guardFreeDirectoryApi(
): Promise<NextResponse | null> {
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' });
}
Expand Down
7 changes: 2 additions & 5 deletions app/api/handle/[handle]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 },
Expand Down
5 changes: 2 additions & 3 deletions app/api/license/products/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down
5 changes: 2 additions & 3 deletions app/api/license/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -26,7 +25,7 @@ export async function GET(request: Request): Promise<NextResponse> {
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;
}
Expand Down
12 changes: 4 additions & 8 deletions app/api/push/subscribe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -126,11 +125,8 @@ async function rateLimited(
wallet: string,
): Promise<NextResponse | null> {
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 {
Expand Down
11 changes: 3 additions & 8 deletions app/api/push/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,11 +20,8 @@ export async function POST(req: Request): Promise<NextResponse> {
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 {
Expand Down
12 changes: 3 additions & 9 deletions app/api/shops/_shared.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -22,7 +21,7 @@ export async function guardFreeShopsApi(
): Promise<NextResponse | null> {
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' });
}
Expand All @@ -34,12 +33,7 @@ export async function guardPaidShopsApi(
): Promise<NextResponse | null> {
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' });
}
Expand Down
7 changes: 3 additions & 4 deletions app/api/tip-messages/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -33,9 +32,9 @@ function privateResponse(response: NextResponse): NextResponse {
async function rateLimitResponse(req: Request): Promise<NextResponse | null> {
let allowed = true;
try {
allowed = await checkIpRateLimit(
allowed = await checkClientIpBucketRateLimit(
req,
'tip-messages',
hashIpBucket(clientIp(req)),
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_SEC,
);
Expand Down
40 changes: 40 additions & 0 deletions lib/net/clientRateLimit.ts
Original file line number Diff line number Diff line change
@@ -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:<scope>:<HMAC(IPv4 /32・IPv6 /64)>`、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<boolean> {
return checkIpRateLimit(scope, hashIpBucket(clientIp(req)), max, windowSec);
}

/**
* ip-prefix 戦略: 鍵 `rl:read:<keyFor(匿名化 prefix)>:<floor(now/窓)>` (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<boolean> {
const ipPrefix = anonymizeIp(clientIp(req) ?? '');
return checkReadRateLimit(keyFor(ipPrefix), max, windowSec);
}
Loading
Loading