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
28 changes: 28 additions & 0 deletions apps/web/src/app/api/v1/rate/__tests__/spec-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/api/v1/rate/inspect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
} from '@/server/rate/extension';
import {
PUBLIC_API_CORS_HEADERS,
extractClientRateLimitKey,
inspectRateLimitResponse,
inspectRateLimiter,
jsonApiError,
withPublicReviewRisk,
parseBoundedStringParam,
Expand All @@ -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'),
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/app/api/v1/rate/lookup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
} from '@/server/rate';
import {
PUBLIC_API_CORS_HEADERS,
extractClientRateLimitKey,
inspectRateLimitResponse,
inspectRateLimiter,
jsonApiError,
withPublicReviewRisk,
parseBoundedStringParam,
Expand All @@ -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'),
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/server/rate/__tests__/core-rate-architecture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/server/rate/api-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -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<string, string>
): 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';
Expand Down
39 changes: 36 additions & 3 deletions apps/web/src/server/rate/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'server-only';
import { createHash } from 'node:crypto';
import { cache } from 'react';
import { sitemapRowsByItem } from '@/lib/item-directory';
import {
Expand Down Expand Up @@ -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
Expand Down
Loading