diff --git a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts index 541b5331..7c371517 100644 --- a/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts +++ b/apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts @@ -321,11 +321,39 @@ async function testExtensionLookup() { console.warn(' โœ… Extension lookup returns the category spec view'); } +// Inspect and lookup share a per-IP limit (DOS/Rate#1307). +async function testInspectRateLimit() { + const { NextRequest } = await import('next/server'); + const inspect = await import('../inspect/route'); + const lookup = await import('../lookup/route'); + const ask = (route: typeof inspect | typeof lookup, ip: string) => + route.GET( + new NextRequest('http://localhost/api/v1/rate/inspect?q=iphone', { + headers: { 'x-forwarded-for': ip }, + }) + ); + for (let i = 0; i < 30; i++) { + const res = await ask(i % 2 ? inspect : lookup, '198.51.100.20'); + assert.notEqual(res.status, 429, `request ${i + 1}`); + } + const limited = await ask(inspect, '198.51.100.20'); + assert.equal(limited.status, 429); + assert.equal(limited.headers.get('retry-after'), '60'); + assert.equal((await ask(lookup, '198.51.100.20')).status, 429); + assert.notEqual( + (await ask(inspect, '198.51.100.21')).status, + 429, + 'another client is not limited' + ); + console.warn(' โœ… Inspect and lookup are rate-limited per client IP'); +} + async function main() { console.warn('๐Ÿงช API v1 category spec view suite (DOS/Rate#1184)'); await testProductFilters(); await testDetailAndCompare(); await testExtensionLookup(); + await testInspectRateLimit(); } main().catch((err) => { diff --git a/apps/web/src/app/api/v1/rate/inspect/route.ts b/apps/web/src/app/api/v1/rate/inspect/route.ts index 87125265..442d2c31 100644 --- a/apps/web/src/app/api/v1/rate/inspect/route.ts +++ b/apps/web/src/app/api/v1/rate/inspect/route.ts @@ -9,6 +9,9 @@ import { } from '@/server/rate/extension'; import { PUBLIC_API_CORS_HEADERS, + extractClientRateLimitKey, + inspectRateLimitResponse, + inspectRateLimiter, jsonApiError, withPublicReviewRisk, parseBoundedStringParam, @@ -31,6 +34,9 @@ export async function OPTIONS() { */ export async function GET(request: NextRequest) { try { + if (!inspectRateLimiter.allow(extractClientRateLimitKey(request.headers))) { + return inspectRateLimitResponse(PUBLIC_API_CORS_HEADERS); + } const sp = request.nextUrl.searchParams; const rawQuery = parseBoundedStringParam( sp.get('q') || sp.get('url'), diff --git a/apps/web/src/app/api/v1/rate/lookup/route.ts b/apps/web/src/app/api/v1/rate/lookup/route.ts index 31391fa4..8876196c 100644 --- a/apps/web/src/app/api/v1/rate/lookup/route.ts +++ b/apps/web/src/app/api/v1/rate/lookup/route.ts @@ -5,6 +5,9 @@ import { } from '@/server/rate'; import { PUBLIC_API_CORS_HEADERS, + extractClientRateLimitKey, + inspectRateLimitResponse, + inspectRateLimiter, jsonApiError, withPublicReviewRisk, parseBoundedStringParam, @@ -25,6 +28,9 @@ export async function OPTIONS() { */ export async function GET(request: NextRequest) { try { + if (!inspectRateLimiter.allow(extractClientRateLimitKey(request.headers))) { + return inspectRateLimitResponse(PUBLIC_API_CORS_HEADERS); + } const sp = request.nextUrl.searchParams; const rawQuery = parseBoundedStringParam( sp.get('q') || sp.get('url'), diff --git a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts index 58ecde85..b86fd861 100644 --- a/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts +++ b/apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts @@ -9044,6 +9044,18 @@ async function runCoreArchitectureSuite() { 'x-forwarded-for': ' 203.0.113.10\t99 , 10.0.0.1 ', }); assert.equal(extractClientRateLimitKey(wsHeaders), '203.0.113.1099'); + // On Vercel the platform-set IP wins over a client-supplied forwarded + // chain; elsewhere that header is client input and ignored (#1307). + const vercelHeaders = new Headers({ + 'x-vercel-forwarded-for': '198.51.100.7', + 'x-forwarded-for': '203.0.113.10, 10.0.0.1', + }); + const previousVercel = process.env.VERCEL; + process.env.VERCEL = '1'; + assert.equal(extractClientRateLimitKey(vercelHeaders), '198.51.100.7'); + delete process.env.VERCEL; + assert.equal(extractClientRateLimitKey(vercelHeaders), '203.0.113.10'); + if (previousVercel !== undefined) process.env.VERCEL = previousVercel; const plausibleBoolMaster = withPlausibleMinPrice( { min_price: true as unknown as number }, diff --git a/apps/web/src/server/rate/api-handler.ts b/apps/web/src/server/rate/api-handler.ts index fbf16503..2a68e2ac 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -710,6 +710,15 @@ export function extractClientRateLimitKey( .replace(/\s+/g, '') .slice(0, 128); }; + // On Vercel, the platform-set client IP first (a client cannot set it + // there); anywhere else that header is ordinary client input and ignored + // (DOS/Rate#1307). + if (process.env.VERCEL === '1') { + const platformIp = sanitizeIpCandidate( + headers.get('x-vercel-forwarded-for')?.split(',')[0] + ); + if (platformIp) return platformIp; + } const forwardedFor = headers.get('x-forwarded-for'); if (forwardedFor) { const firstIp = sanitizeIpCandidate(forwardedFor.split(',')[0]); @@ -720,6 +729,30 @@ export function extractClientRateLimitKey( return 'anon'; } +/** + * Per-IP limit of the uncached inspection routes (`/inspect`, `/lookup`), + * per server instance (DOS/Rate#1307). A durable shared limit is #1247. + */ +export const inspectRateLimiter = new BoundedSlidingWindowRateLimiter({ + windowMs: 60_000, + maxRequests: 30, + maxBuckets: 5_000, +}); + +/** The 429 the inspection routes answer past `inspectRateLimiter`. */ +export function inspectRateLimitResponse( + headers: Record +): NextResponse { + return NextResponse.json( + { + ok: false, + error: 'Rate limit exceeded. Maximum 30 inspections per minute.', + code: 'RATE_LIMIT_EXCEEDED', + }, + { status: 429, headers: { ...headers, 'Retry-After': '60' } } + ); +} + export type ReviewVoteAction = 'up' | 'down' | 'report' | 'clear'; export type ReviewReportReason = 'spam' | 'fake' | 'offensive' | 'off_topic' | 'other'; diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index b7e10e93..4ab93c2a 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -1,4 +1,5 @@ import 'server-only'; +import { createHash } from 'node:crypto'; import { cache } from 'react'; import { sitemapRowsByItem } from '@/lib/item-directory'; import { @@ -565,9 +566,41 @@ export const getCachedFilteredReviewCountsByMaster = cache(() => )() ); -export const getCachedInspectProductOrShopUrl = cache( - inspectProductOrShopUrlWithRadar -); +// One Data Cache entry per normalized query for 2 minutes, so repeating a +// query does not rerun its reads (DOS/Rate#1307). The key is a hash: the +// query is up to 2 KB of caller text. A result too large for an entry (Next +// skips entries over 2 MB) is cached as a marker and read uncached. +const INSPECT_CACHE_MAX_BYTES = 1_500_000; +const _getCachedInspectionByKey = cache(async (query: string) => { + const cached = await withRateDataCache( + async (q: string) => { + const result = await inspectProductOrShopUrlWithRadar(q); + return JSON.stringify(result).length > INSPECT_CACHE_MAX_BYTES + ? { tooLarge: true as const } + : result; + }, + [ + 'rate-inspect', + createHash('sha256').update(query).digest('hex').slice(0, 32), + ], + { + revalidate: 120, + tags: [ + RATE_CACHE_TAGS.PRODUCTS, + RATE_CACHE_TAGS.OFFERS, + RATE_CACHE_TAGS.REVIEWS, + RATE_CACHE_TAGS.MERCHANTS, + ], + } + )(query); + return 'tooLarge' in cached + ? inspectProductOrShopUrlWithRadar(query) + : cached; +}); + +export function getCachedInspectProductOrShopUrl(rawQuery: string) { + return _getCachedInspectionByKey(sanitizeCachedInspectQuery(rawQuery)); +} // A product URL only needs its category, so only category_slug is kept from // metadata and specifications: whole spec sheets would push the entry past