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
7 changes: 7 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ SENTRY_DSN=
# PostHog (optional — enables analytics)
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=

# Upstash Redis (optional — enables cross-instance webhook rate limiting).
# Without these, the rate limiter falls back to an in-memory Map per function
# instance. Provision via Vercel Marketplace → Upstash Redis; both vars are
# populated automatically into your Vercel env.
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
2 changes: 1 addition & 1 deletion apps/web/app/api/line/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type LineEvent = LineFollowEvent | LineMessageEvent | { type: string }

export async function POST(req: NextRequest) {
const ip = getClientIp(req.headers)
if (!checkRateLimit(ip)) {
if (!(await checkRateLimit(ip))) {
logger.warn({ ip }, 'LINE webhook: rate limit exceeded')
return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 })
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/telegram/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ async function sendTelegramMessage(chatId: number, text: string) {

export async function POST(req: NextRequest) {
const ip = getClientIp(req.headers)
if (!checkRateLimit(ip)) {
if (!(await checkRateLimit(ip))) {
logger.warn({ ip }, 'Telegram webhook: rate limit exceeded')
return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 })
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NextResponse } from 'next/server'

export async function GET(request: Request) {
const ip = getClientIp(request.headers)
if (!checkRateLimit(ip, 30)) {
if (!(await checkRateLimit(ip, 30))) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}

Expand Down
34 changes: 19 additions & 15 deletions apps/web/lib/__tests__/rate-limiter.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,40 @@
import { describe, it, expect } from 'vitest'
import { checkRateLimit } from '../utils/rate-limiter.js'

describe('checkRateLimit', () => {
it('allows requests under the limit', () => {
// These tests exercise the in-memory fallback path. UPSTASH_REDIS_REST_URL is
// unset in the test env, so `checkRateLimit` falls through to the Map-based
// implementation and behaves identically to the pre-Upstash version.

describe('checkRateLimit (in-memory fallback)', () => {
it('allows requests under the limit', async () => {
const ip = `rate-test-allow-${Date.now()}`
expect(checkRateLimit(ip)).toBe(true)
expect(await checkRateLimit(ip)).toBe(true)
})

it('blocks requests at the limit', () => {
it('blocks requests at the limit', async () => {
const ip = `rate-test-block-${Date.now()}`
const MAX = 120
for (let i = 0; i < MAX; i++) checkRateLimit(ip)
expect(checkRateLimit(ip)).toBe(false)
for (let i = 0; i < MAX; i++) await checkRateLimit(ip)
expect(await checkRateLimit(ip)).toBe(false)
})

it('isolates different IPs independently', () => {
it('isolates different IPs independently', async () => {
const ip1 = `rate-test-iso1-${Date.now()}`
const ip2 = `rate-test-iso2-${Date.now()}`
const MAX = 120
for (let i = 0; i < MAX; i++) checkRateLimit(ip1)
expect(checkRateLimit(ip1)).toBe(false)
expect(checkRateLimit(ip2)).toBe(true)
for (let i = 0; i < MAX; i++) await checkRateLimit(ip1)
expect(await checkRateLimit(ip1)).toBe(false)
expect(await checkRateLimit(ip2)).toBe(true)
})

it('allows the first request for a new IP', () => {
it('allows the first request for a new IP', async () => {
const ip = `rate-test-new-${Date.now()}`
expect(checkRateLimit(ip)).toBe(true)
expect(await checkRateLimit(ip)).toBe(true)
})

it('respects a custom limit', () => {
it('respects a custom limit', async () => {
const ip = `rate-test-custom-${Date.now()}`
for (let i = 0; i < 5; i++) checkRateLimit(ip, 5)
expect(checkRateLimit(ip, 5)).toBe(false)
for (let i = 0; i < 5; i++) await checkRateLimit(ip, 5)
expect(await checkRateLimit(ip, 5)).toBe(false)
})
})
112 changes: 93 additions & 19 deletions apps/web/lib/utils/rate-limiter.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,82 @@
/**
* Simple per-instance rate limiter for webhook endpoints.
* Rate limiter for webhook and auth-callback endpoints.
*
* State resets on cold starts and does not share across Vercel function
* instances — it's best-effort protection, not a hard guarantee. In practice
* that's fine here because the real authentication for each webhook is
* HMAC-based (Telegram `x-telegram-bot-api-secret-token` / LINE `X-Line-Signature`),
* verified with `timingSafeEqual`. The rate limiter is a second line of defense:
* it cheaply absorbs bursts before we spend CPU on signature verification or DB.
* Two modes, auto-selected at module load:
*
* If webhook traffic grows beyond the single-instance budget, swap the
* `_windows` Map for a shared store (Upstash Redis, Vercel Runtime Cache).
* Keep the function signature identical so call sites don't change.
* 1. **Upstash Redis** (preferred, production) — when both
* `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are set.
* Sliding-window limit shared across every Vercel function instance
* and region. Set these via the Upstash marketplace integration in
* Vercel or `vercel env add`.
*
* Usage: place the check BEFORE expensive auth/DB operations.
* 2. **In-memory Map fallback** — when Upstash env vars are absent
* (local dev, preview deploys without the integration, or incident
* recovery). Per-instance state; resets on cold start. The HMAC
* signature check remains the real auth, so this is defense-in-depth.
*
* The public API is `checkRateLimit(ip, limitPerMinute)` returning
* `Promise<boolean>`. Callers `await` it identically in both modes.
*/
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { logger } from '@/lib/logger'

const DEFAULT_LIMIT = 120
const WINDOW_MS = 60_000

// ── Upstash branch (only wired up when env vars are present) ──────────────

const hasUpstashEnv =
typeof process.env.UPSTASH_REDIS_REST_URL === 'string'
&& process.env.UPSTASH_REDIS_REST_URL.length > 0
&& typeof process.env.UPSTASH_REDIS_REST_TOKEN === 'string'
&& process.env.UPSTASH_REDIS_REST_TOKEN.length > 0

// `Redis.fromEnv()` throws on malformed values (e.g. a typo when running
// `vercel env add`). Catch at module load so the route keeps working on the
// in-memory fallback instead of crashing every cold start.
let upstashRedis: Redis | null = null
if (hasUpstashEnv) {
try {
upstashRedis = Redis.fromEnv()
} catch (err) {
logger.error(
{ err },
'rate-limiter: Upstash env vars are set but Redis.fromEnv() failed; falling back to in-memory',
)
upstashRedis = null
}
}

// Cache ratelimit instances by limit-per-minute so the `checkRateLimit(ip, 30)`
// variant (auth callback) doesn't share state with the default webhook limit.
const ratelimitByLimit = new Map<number, Ratelimit>()

function getUpstashRatelimiter(limitPerMinute: number): Ratelimit | null {
if (!upstashRedis) return null
let rl = ratelimitByLimit.get(limitPerMinute)
if (!rl) {
rl = new Ratelimit({
redis: upstashRedis,
limiter: Ratelimit.slidingWindow(limitPerMinute, '1 m'),
prefix: `caffecode:webhook:${limitPerMinute}`,
analytics: false,
})
ratelimitByLimit.set(limitPerMinute, rl)
}
return rl
}

// ── In-memory fallback ──────────────────────────────────────────────────

const _windows = new Map<string, { count: number; resetAt: number }>()

/**
* Returns true if the request is allowed, false if the rate limit is exceeded.
* @param ip Client IP address (from x-forwarded-for)
* @param limitPerMinute Max requests allowed per IP per 60-second window
*/
export function checkRateLimit(ip: string, limitPerMinute = 120): boolean {
function checkInMemory(ip: string, limitPerMinute: number): boolean {
const now = Date.now()
const entry = _windows.get(ip)

if (!entry || entry.resetAt < now) {
_windows.set(ip, { count: 1, resetAt: now + 60_000 })
_windows.set(ip, { count: 1, resetAt: now + WINDOW_MS })
return true
}

Expand All @@ -37,7 +86,7 @@ export function checkRateLimit(ip: string, limitPerMinute = 120): boolean {

entry.count++

// Prune expired entries to prevent unbounded Map growth
// Prune expired entries to prevent unbounded Map growth.
if (_windows.size > 10_000) {
for (const [k, v] of _windows) {
if (v.resetAt < now) _windows.delete(k)
Expand All @@ -47,6 +96,31 @@ export function checkRateLimit(ip: string, limitPerMinute = 120): boolean {
return true
}

// ── Public API ──────────────────────────────────────────────────────────

/**
* Returns `true` if the request from `ip` is allowed under the given
* per-minute limit. Uses Upstash Redis when configured, otherwise an
* in-memory sliding window.
*
* On an Upstash outage the helper falls back to the in-memory path so
* webhook delivery is never blocked by a cache failure.
*/
export async function checkRateLimit(ip: string, limitPerMinute = DEFAULT_LIMIT): Promise<boolean> {
const rl = getUpstashRatelimiter(limitPerMinute)
if (rl) {
try {
const { success } = await rl.limit(ip)
return success
} catch (err) {
// Upstash unreachable — log once per request and fall through to
// in-memory so webhooks keep flowing through the incident.
logger.warn({ err, ip }, 'rate-limiter: Upstash call failed, falling back to in-memory')
}
}
return checkInMemory(ip, limitPerMinute)
}

/** Extract the real client IP from Next.js request headers. */
export function getClientIp(headers: { get(name: string): string | null }): string {
return headers.get('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown'
Expand Down
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"@sentry/nextjs": "^10.40.0",
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.97.0",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.37.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
Expand Down
52 changes: 44 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
"SENTRY_DSN",
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST",
"CRON_SECRET"
"CRON_SECRET",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN",
"PUSH_SELECT_CONCURRENCY",
"PUSH_DISPATCH_CONCURRENCY"
]
},
"dev": {
Expand Down
Loading