diff --git a/apps/web/src/app/api/checkout_sessions/route.ts b/apps/web/src/app/api/checkout_sessions/route.ts index 6bbe487c9..6fe053c4a 100644 --- a/apps/web/src/app/api/checkout_sessions/route.ts +++ b/apps/web/src/app/api/checkout_sessions/route.ts @@ -1,8 +1,51 @@ import type { NextRequest } from 'next/server' import type { Stripe } from 'stripe' +import { + buildRateLimitKey, + checkRateLimit, + isSameOriginRequest, +} from '@/lib/request-security' +import { shouldBlockTraffic } from '@/lib/traffic-guard' import { stripe } from '@/services/stripe' export async function POST(req: NextRequest) { + const country = req.headers.get('x-vercel-ip-country') ?? req.headers.get('cf-ipcountry') + const userAgent = req.headers.get('user-agent') + + if (shouldBlockTraffic({ country, userAgent })) { + return new Response(null, { + status: 403, + headers: { + 'cache-control': 'public, max-age=300, s-maxage=300', + }, + }) + } + + if (!isSameOriginRequest(req)) { + return new Response(null, { + status: 403, + headers: { + 'cache-control': 'public, max-age=300, s-maxage=300', + }, + }) + } + + const rateLimit = checkRateLimit({ + key: buildRateLimitKey(req, 'checkout_sessions'), + limit: 12, + windowMs: 60_000, + }) + + if (!rateLimit.allowed) { + return new Response(null, { + status: 429, + headers: { + 'retry-after': String(rateLimit.retryAfterSeconds), + 'cache-control': 'public, max-age=60, s-maxage=60', + }, + }) + } + if (!stripe) { return Response.json({ error: 'Stripe is not configured' }, { status: 503 }) } @@ -15,7 +58,6 @@ export async function POST(req: NextRequest) { const locale = (url.searchParams.get('locale') ?? 'en') as Stripe.Checkout.SessionCreateParams.Locale - const country = req.headers.get('x-vercel-ip-country') const prices = await stripe.prices.list({ product: process.env.STRIPE_PRODUCT_ID, diff --git a/apps/web/src/app/api/proxy/route.ts b/apps/web/src/app/api/proxy/route.ts index 2ec09cfc9..0674577ea 100644 --- a/apps/web/src/app/api/proxy/route.ts +++ b/apps/web/src/app/api/proxy/route.ts @@ -1,4 +1,11 @@ +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { + buildRateLimitKey, + checkRateLimit, + isSameOriginRequest, +} from '@/lib/request-security' +import { shouldBlockTraffic } from '@/lib/traffic-guard' // Limit function execution time to 10 seconds export const maxDuration = 10 @@ -19,7 +26,40 @@ function isAllowedUrl(urlString: string): boolean { } } -export async function GET(request: Request) { +export async function GET(request: NextRequest) { + const country = + request.headers.get('x-vercel-ip-country') ?? + request.headers.get('cf-ipcountry') + const userAgent = request.headers.get('user-agent') + + if (shouldBlockTraffic({ country, userAgent })) { + return new NextResponse(null, { + status: 403, + headers: { + 'cache-control': 'public, max-age=300, s-maxage=300', + }, + }) + } + + if (!isSameOriginRequest(request)) { + return new NextResponse(null, { status: 403 }) + } + + const rateLimit = checkRateLimit({ + key: buildRateLimitKey(request, 'api-proxy'), + limit: 60, + windowMs: 60_000, + }) + + if (!rateLimit.allowed) { + return new NextResponse(null, { + status: 429, + headers: { + 'retry-after': String(rateLimit.retryAfterSeconds), + }, + }) + } + const { searchParams } = new URL(request.url) const url = searchParams.get('url') diff --git a/apps/web/src/lib/request-security.ts b/apps/web/src/lib/request-security.ts new file mode 100644 index 000000000..acd5dd1b1 --- /dev/null +++ b/apps/web/src/lib/request-security.ts @@ -0,0 +1,101 @@ +import type { NextRequest } from 'next/server' + +type RateLimitOptions = { + key: string + limit: number + windowMs: number +} + +type RateLimitEntry = { + count: number + expiresAt: number +} + +const GLOBAL_RATE_LIMIT_KEY = '__plotwistRateLimitStore__' + +function getStore(): Map { + const globalState = globalThis as typeof globalThis & { + [GLOBAL_RATE_LIMIT_KEY]?: Map + } + + if (!globalState[GLOBAL_RATE_LIMIT_KEY]) { + globalState[GLOBAL_RATE_LIMIT_KEY] = new Map() + } + + return globalState[GLOBAL_RATE_LIMIT_KEY] +} + +export function getClientIp(request: NextRequest): string { + const forwardedFor = request.headers.get('x-forwarded-for') + if (forwardedFor) { + return forwardedFor.split(',')[0]?.trim() ?? 'unknown' + } + + return ( + request.headers.get('x-real-ip') ?? + request.headers.get('cf-connecting-ip') ?? + 'unknown' + ) +} + +export function buildRateLimitKey(request: NextRequest, route: string): string { + return `${route}:${getClientIp(request)}` +} + +export function checkRateLimit({ + key, + limit, + windowMs, +}: RateLimitOptions): { + allowed: boolean + retryAfterSeconds: number +} { + const now = Date.now() + const store = getStore() + const entry = store.get(key) + + if (!entry || entry.expiresAt <= now) { + store.set(key, { + count: 1, + expiresAt: now + windowMs, + }) + + return { allowed: true, retryAfterSeconds: 0 } + } + + if (entry.count >= limit) { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((entry.expiresAt - now) / 1000) + ) + return { allowed: false, retryAfterSeconds } + } + + entry.count += 1 + store.set(key, entry) + + return { allowed: true, retryAfterSeconds: 0 } +} + +export function isSameOriginRequest(request: NextRequest): boolean { + const requestOrigin = request.nextUrl.origin + const originHeader = request.headers.get('origin') + const refererHeader = request.headers.get('referer') + const secFetchSite = request.headers.get('sec-fetch-site') + + if (originHeader === requestOrigin) { + return true + } + + if (refererHeader) { + try { + if (new URL(refererHeader).origin === requestOrigin) { + return true + } + } catch { + return false + } + } + + return secFetchSite === 'same-origin' || secFetchSite === 'same-site' +} diff --git a/apps/web/src/lib/traffic-guard.ts b/apps/web/src/lib/traffic-guard.ts new file mode 100644 index 000000000..ae645f16a --- /dev/null +++ b/apps/web/src/lib/traffic-guard.ts @@ -0,0 +1,42 @@ +const KNOWN_CRAWLER_UA_PATTERN = + /(googlebot|bingbot|duckduckbot|slurp|baiduspider|yandexbot|applebot|petalbot|facebookexternalhit|facebot|twitterbot|linkedinbot|slackbot|discordbot|whatsapp|telegrambot)/i + +const SUSPICIOUS_UA_PATTERN = + /(curl|wget|python-requests|python-urllib|go-http-client|java\/|libwww-perl|okhttp|postmanruntime|insomnia|headless|phantomjs|scrapy|nikto|nmap|sqlmap|masscan|zgrab)/i + +const BLOCKED_TRAFFIC_COUNTRIES = new Set(['SG']) + +type TrafficGuardOptions = { + country?: string | null + userAgent?: string | null + allowKnownCrawlers?: boolean +} + +export function shouldBlockTraffic({ + country, + userAgent, + allowKnownCrawlers = false, +}: TrafficGuardOptions): boolean { + const normalizedCountry = country?.trim().toUpperCase() + const knownCrawler = Boolean(userAgent && KNOWN_CRAWLER_UA_PATTERN.test(userAgent)) + + if (allowKnownCrawlers && knownCrawler) { + return false + } + + if (normalizedCountry && BLOCKED_TRAFFIC_COUNTRIES.has(normalizedCountry)) { + return true + } + + // Requests without UA or with known scripted signatures are almost always + // automated abuse, and they are a major source of avoidable edge/function costs. + if (!userAgent) { + return true + } + + if (SUSPICIOUS_UA_PATTERN.test(userAgent)) { + return true + } + + return false +} diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 21a61ea1e..3a4178249 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -1,6 +1,7 @@ import { match } from '@formatjs/intl-localematcher' import Negotiator from 'negotiator' import { type NextRequest, NextResponse } from 'next/server' +import { shouldBlockTraffic } from '@/lib/traffic-guard' import { languages as appLanguages } from '../languages' const headers = { 'accept-language': 'en-US' } @@ -13,6 +14,19 @@ match(languages, appLanguages, DEFAULT_LOCALE) export function proxy(req: NextRequest) { const { pathname } = req.nextUrl + const country = + req.headers.get('x-vercel-ip-country') ?? req.headers.get('cf-ipcountry') + const userAgent = req.headers.get('user-agent') + + if (shouldBlockTraffic({ country, userAgent, allowKnownCrawlers: true })) { + return new NextResponse(null, { + status: 403, + headers: { + 'cache-control': 'public, max-age=300, s-maxage=300', + }, + }) + } + // Short URLs (/s/1Tu4V) are handled by app/s/[shortCode]/page.tsx which serves // OG metadata for social bots and a JS redirect for real users. if (pathname.startsWith('/s/')) {