From e9ebaabdb8c4c58a7e837220ac6f38f6852fd807 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 03:33:10 +0000 Subject: [PATCH 1/3] feat(web): block suspicious SG traffic at edge and api Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .env.example | 1 + .../src/app/api/checkout_sessions/route.ts | 14 ++++++- apps/web/src/app/api/proxy/route.ts | 18 ++++++++- apps/web/src/lib/traffic-guard.ts | 37 +++++++++++++++++++ apps/web/src/proxy.ts | 14 +++++++ 5 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/traffic-guard.ts diff --git a/.env.example b/.env.example index 2e7999347..a34c315ea 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ NEXT_PUBLIC_TMDB_API_KEY= NEXT_PUBLIC_MEASUREMENT_ID= NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= +BLOCKED_TRAFFIC_COUNTRIES=SG diff --git a/apps/web/src/app/api/checkout_sessions/route.ts b/apps/web/src/app/api/checkout_sessions/route.ts index 6bbe487c9..5d01aa21f 100644 --- a/apps/web/src/app/api/checkout_sessions/route.ts +++ b/apps/web/src/app/api/checkout_sessions/route.ts @@ -1,8 +1,21 @@ import type { NextRequest } from 'next/server' import type { Stripe } from 'stripe' +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 (!stripe) { return Response.json({ error: 'Stripe is not configured' }, { status: 503 }) } @@ -15,7 +28,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..c70bd4cf1 100644 --- a/apps/web/src/app/api/proxy/route.ts +++ b/apps/web/src/app/api/proxy/route.ts @@ -1,4 +1,6 @@ +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import { shouldBlockTraffic } from '@/lib/traffic-guard' // Limit function execution time to 10 seconds export const maxDuration = 10 @@ -19,7 +21,21 @@ 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', + }, + }) + } + const { searchParams } = new URL(request.url) const url = searchParams.get('url') diff --git a/apps/web/src/lib/traffic-guard.ts b/apps/web/src/lib/traffic-guard.ts new file mode 100644 index 000000000..44723d972 --- /dev/null +++ b/apps/web/src/lib/traffic-guard.ts @@ -0,0 +1,37 @@ +const KNOWN_CRAWLER_UA_PATTERN = + /(googlebot|bingbot|duckduckbot|slurp|baiduspider|yandexbot|applebot|petalbot|facebookexternalhit|facebot|twitterbot|linkedinbot|slackbot|discordbot|whatsapp|telegrambot)/i + +const BLOCKED_TRAFFIC_COUNTRIES = new Set( + (process.env.BLOCKED_TRAFFIC_COUNTRIES ?? 'SG') + .split(',') + .map(country => country.trim().toUpperCase()) + .filter(Boolean) +) + +type TrafficGuardOptions = { + country?: string | null + userAgent?: string | null + allowKnownCrawlers?: boolean +} + +export function shouldBlockTraffic({ + country, + userAgent, + allowKnownCrawlers = false, +}: TrafficGuardOptions): boolean { + const normalizedCountry = country?.trim().toUpperCase() + + if (!normalizedCountry) { + return false + } + + if (!BLOCKED_TRAFFIC_COUNTRIES.has(normalizedCountry)) { + return false + } + + if (allowKnownCrawlers && userAgent && KNOWN_CRAWLER_UA_PATTERN.test(userAgent)) { + return false + } + + return true +} 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/')) { From b3f6a5a9f3791a1eb84581769c045d12240885f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 03:37:06 +0000 Subject: [PATCH 2/3] refactor(web): hardcode SG traffic block in guard Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .env.example | 1 - apps/web/src/lib/traffic-guard.ts | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.env.example b/.env.example index a34c315ea..2e7999347 100644 --- a/.env.example +++ b/.env.example @@ -2,5 +2,4 @@ NEXT_PUBLIC_TMDB_API_KEY= NEXT_PUBLIC_MEASUREMENT_ID= NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= -BLOCKED_TRAFFIC_COUNTRIES=SG diff --git a/apps/web/src/lib/traffic-guard.ts b/apps/web/src/lib/traffic-guard.ts index 44723d972..44333bf9f 100644 --- a/apps/web/src/lib/traffic-guard.ts +++ b/apps/web/src/lib/traffic-guard.ts @@ -1,12 +1,7 @@ const KNOWN_CRAWLER_UA_PATTERN = /(googlebot|bingbot|duckduckbot|slurp|baiduspider|yandexbot|applebot|petalbot|facebookexternalhit|facebot|twitterbot|linkedinbot|slackbot|discordbot|whatsapp|telegrambot)/i -const BLOCKED_TRAFFIC_COUNTRIES = new Set( - (process.env.BLOCKED_TRAFFIC_COUNTRIES ?? 'SG') - .split(',') - .map(country => country.trim().toUpperCase()) - .filter(Boolean) -) +const BLOCKED_TRAFFIC_COUNTRIES = new Set(['SG']) type TrafficGuardOptions = { country?: string | null From e248ea553a96c2fc7c20d71bf7f6746b6c552374 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 03:41:24 +0000 Subject: [PATCH 3/3] feat(web): harden API abuse protection with origin and rate limits Co-authored-by: Luiz Henrique <7henrique18@gmail.com> --- .../src/app/api/checkout_sessions/route.ts | 30 ++++++ apps/web/src/app/api/proxy/route.ts | 24 +++++ apps/web/src/lib/request-security.ts | 101 ++++++++++++++++++ apps/web/src/lib/traffic-guard.ts | 22 ++-- 4 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/request-security.ts diff --git a/apps/web/src/app/api/checkout_sessions/route.ts b/apps/web/src/app/api/checkout_sessions/route.ts index 5d01aa21f..6fe053c4a 100644 --- a/apps/web/src/app/api/checkout_sessions/route.ts +++ b/apps/web/src/app/api/checkout_sessions/route.ts @@ -1,5 +1,10 @@ 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' @@ -16,6 +21,31 @@ export async function POST(req: NextRequest) { }) } + 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 }) } diff --git a/apps/web/src/app/api/proxy/route.ts b/apps/web/src/app/api/proxy/route.ts index c70bd4cf1..0674577ea 100644 --- a/apps/web/src/app/api/proxy/route.ts +++ b/apps/web/src/app/api/proxy/route.ts @@ -1,5 +1,10 @@ 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 @@ -36,6 +41,25 @@ export async function GET(request: NextRequest) { }) } + 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 index 44333bf9f..ae645f16a 100644 --- a/apps/web/src/lib/traffic-guard.ts +++ b/apps/web/src/lib/traffic-guard.ts @@ -1,6 +1,9 @@ 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 = { @@ -15,18 +18,25 @@ export function shouldBlockTraffic({ allowKnownCrawlers = false, }: TrafficGuardOptions): boolean { const normalizedCountry = country?.trim().toUpperCase() + const knownCrawler = Boolean(userAgent && KNOWN_CRAWLER_UA_PATTERN.test(userAgent)) - if (!normalizedCountry) { + if (allowKnownCrawlers && knownCrawler) { return false } - if (!BLOCKED_TRAFFIC_COUNTRIES.has(normalizedCountry)) { - return false + if (normalizedCountry && BLOCKED_TRAFFIC_COUNTRIES.has(normalizedCountry)) { + return true } - if (allowKnownCrawlers && userAgent && KNOWN_CRAWLER_UA_PATTERN.test(userAgent)) { - return false + // 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 true + return false }