From a945f39260c7719bb296a41841eb3196aa269b75 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:44:17 +0700 Subject: [PATCH 1/2] fix(web): rate-limit inspect and lookup and cache their results DOS/Rate#1307. /api/v1/rate/inspect and /lookup ran several database reads per distinct query with no rate limit, and their result was cached for one request only. - both routes share a per-IP limit of 30 per minute per instance (429 with Retry-After); a durable shared limit is #1247; - the inspection result is cached in the Data Cache for 2 minutes per normalized query (hashed key), also for the inspect page; - the client key prefers the platform-set x-vercel-forwarded-for over a client-supplied forwarded chain (also for the extension routes). Closes #1307 Co-Authored-By: Claude Opus 5.5 --- .../api/v1/rate/__tests__/spec-scope.test.ts | 28 +++++++++++++++++ apps/web/src/app/api/v1/rate/inspect/route.ts | 6 ++++ apps/web/src/app/api/v1/rate/lookup/route.ts | 6 ++++ .../__tests__/core-rate-architecture.test.ts | 10 +++++++ apps/web/src/server/rate/api-handler.ts | 30 +++++++++++++++++++ apps/web/src/server/rate/index.ts | 27 +++++++++++++++-- 6 files changed, 105 insertions(+), 2 deletions(-) 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..aa22f049 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-vercel-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..9fec5dde 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,16 @@ async function runCoreArchitectureSuite() { 'x-forwarded-for': ' 203.0.113.10\t99 , 10.0.0.1 ', }); assert.equal(extractClientRateLimitKey(wsHeaders), '203.0.113.1099'); + // The platform-set IP wins over a client-supplied forwarded chain (#1307). + assert.equal( + extractClientRateLimitKey( + new Headers({ + 'x-vercel-forwarded-for': '198.51.100.7', + 'x-forwarded-for': '203.0.113.10, 10.0.0.1', + }) + ), + '198.51.100.7' + ); 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..d1dd599d 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -710,6 +710,12 @@ export function extractClientRateLimitKey( .replace(/\s+/g, '') .slice(0, 128); }; + // The platform-set client IP first (Vercel writes it; a client cannot), then + // the forwarded chain (DOS/Rate#1307). + 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 +726,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..a1a75229 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,10 +566,32 @@ 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. +const _getCachedInspectionByKey = cache((query: string) => + withRateDataCache( + inspectProductOrShopUrlWithRadar, + [ + '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) ); +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 // the Data Cache's 2 MB limit as the catalogue grows. The Data Cache stores From 3093957f625029a0274a87dcbd458014ff45b122 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:49:19 +0700 Subject: [PATCH 2/2] fix(web): trust the vercel ip header only on vercel; skip oversized cache entries Codex round 1 on DOS/Rate#1307: - x-vercel-forwarded-for is read only when VERCEL=1; elsewhere it is client input and the forwarded chain is used as before; - an inspection result over 1.5 MB (Next skips entries over 2 MB) is cached as a marker and read uncached, instead of a failed write. Refs #1307 Co-Authored-By: Claude Opus 5.5 --- .../api/v1/rate/__tests__/spec-scope.test.ts | 2 +- .../__tests__/core-rate-architecture.test.ts | 22 ++++++++++--------- apps/web/src/server/rate/api-handler.ts | 15 ++++++++----- apps/web/src/server/rate/index.ts | 22 ++++++++++++++----- 4 files changed, 38 insertions(+), 23 deletions(-) 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 aa22f049..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 @@ -329,7 +329,7 @@ async function testInspectRateLimit() { const ask = (route: typeof inspect | typeof lookup, ip: string) => route.GET( new NextRequest('http://localhost/api/v1/rate/inspect?q=iphone', { - headers: { 'x-vercel-forwarded-for': ip }, + headers: { 'x-forwarded-for': ip }, }) ); for (let i = 0; i < 30; i++) { 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 9fec5dde..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,16 +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'); - // The platform-set IP wins over a client-supplied forwarded chain (#1307). - assert.equal( - extractClientRateLimitKey( - new Headers({ - 'x-vercel-forwarded-for': '198.51.100.7', - 'x-forwarded-for': '203.0.113.10, 10.0.0.1', - }) - ), - '198.51.100.7' - ); + // 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 d1dd599d..2a68e2ac 100644 --- a/apps/web/src/server/rate/api-handler.ts +++ b/apps/web/src/server/rate/api-handler.ts @@ -710,12 +710,15 @@ export function extractClientRateLimitKey( .replace(/\s+/g, '') .slice(0, 128); }; - // The platform-set client IP first (Vercel writes it; a client cannot), then - // the forwarded chain (DOS/Rate#1307). - const platformIp = sanitizeIpCandidate( - headers.get('x-vercel-forwarded-for')?.split(',')[0] - ); - if (platformIp) return platformIp; + // 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]); diff --git a/apps/web/src/server/rate/index.ts b/apps/web/src/server/rate/index.ts index a1a75229..4ab93c2a 100644 --- a/apps/web/src/server/rate/index.ts +++ b/apps/web/src/server/rate/index.ts @@ -568,10 +568,17 @@ export const getCachedFilteredReviewCountsByMaster = cache(() => // 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. -const _getCachedInspectionByKey = cache((query: string) => - withRateDataCache( - inspectProductOrShopUrlWithRadar, +// 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), @@ -585,8 +592,11 @@ const _getCachedInspectionByKey = cache((query: string) => RATE_CACHE_TAGS.MERCHANTS, ], } - )(query) -); + )(query); + return 'tooLarge' in cached + ? inspectProductOrShopUrlWithRadar(query) + : cached; +}); export function getCachedInspectProductOrShopUrl(rawQuery: string) { return _getCachedInspectionByKey(sanitizeCachedInspectQuery(rawQuery));