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
44 changes: 43 additions & 1 deletion apps/web/src/app/api/checkout_sessions/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
Expand All @@ -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,
Expand Down
42 changes: 41 additions & 1 deletion apps/web/src/app/api/proxy/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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')

Expand Down
101 changes: 101 additions & 0 deletions apps/web/src/lib/request-security.ts
Original file line number Diff line number Diff line change
@@ -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<string, RateLimitEntry> {
const globalState = globalThis as typeof globalThis & {
[GLOBAL_RATE_LIMIT_KEY]?: Map<string, RateLimitEntry>
}

if (!globalState[GLOBAL_RATE_LIMIT_KEY]) {
globalState[GLOBAL_RATE_LIMIT_KEY] = new Map<string, RateLimitEntry>()
}

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'
}
42 changes: 42 additions & 0 deletions apps/web/src/lib/traffic-guard.ts
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions apps/web/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -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' }
Expand All @@ -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/')) {
Expand Down
Loading