diff --git a/apps/web/.env.example b/apps/web/.env.example index 96c720d..9f998a4 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -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= diff --git a/apps/web/app/api/line/webhook/route.ts b/apps/web/app/api/line/webhook/route.ts index 1d6997b..f389fd6 100644 --- a/apps/web/app/api/line/webhook/route.ts +++ b/apps/web/app/api/line/webhook/route.ts @@ -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 }) } diff --git a/apps/web/app/api/telegram/webhook/route.ts b/apps/web/app/api/telegram/webhook/route.ts index 2612c13..ec1015d 100644 --- a/apps/web/app/api/telegram/webhook/route.ts +++ b/apps/web/app/api/telegram/webhook/route.ts @@ -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 }) } diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index 198b385..218acbc 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -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 }) } diff --git a/apps/web/lib/__tests__/rate-limiter.test.ts b/apps/web/lib/__tests__/rate-limiter.test.ts index a1560c4..39da473 100644 --- a/apps/web/lib/__tests__/rate-limiter.test.ts +++ b/apps/web/lib/__tests__/rate-limiter.test.ts @@ -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) }) }) diff --git a/apps/web/lib/utils/rate-limiter.ts b/apps/web/lib/utils/rate-limiter.ts index def20a9..efdd201 100644 --- a/apps/web/lib/utils/rate-limiter.ts +++ b/apps/web/lib/utils/rate-limiter.ts @@ -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`. 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() + +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() -/** - * 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 } @@ -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) @@ -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 { + 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' diff --git a/apps/web/package.json b/apps/web/package.json index 3139d3d..9992db8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb5be89..0fd8e59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,12 @@ importers: '@supabase/supabase-js': specifier: ^2.97.0 version: 2.97.0 + '@upstash/ratelimit': + specifier: ^2.0.8 + version: 2.0.8(@upstash/redis@1.37.0) + '@upstash/redis': + specifier: ^1.37.0 + version: 1.37.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -2827,6 +2833,18 @@ packages: cpu: [x64] os: [win32] + '@upstash/core-analytics@0.0.10': + resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} + engines: {node: '>=16.0.0'} + + '@upstash/ratelimit@2.0.8': + resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==} + peerDependencies: + '@upstash/redis': ^1.34.3 + + '@upstash/redis@1.37.0': + resolution: {integrity: sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==} + '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -5752,6 +5770,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -8774,6 +8795,19 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/core-analytics@0.0.10': + dependencies: + '@upstash/redis': 1.37.0 + + '@upstash/ratelimit@2.0.8(@upstash/redis@1.37.0)': + dependencies: + '@upstash/core-analytics': 0.0.10 + '@upstash/redis': 1.37.0 + + '@upstash/redis@1.37.0': + dependencies: + uncrypto: 0.1.3 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 @@ -9599,8 +9633,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.6 eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.3(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3(jiti@2.6.1)) @@ -9622,7 +9656,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -9633,22 +9667,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.3(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9659,7 +9693,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.3(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)))(eslint@9.39.3(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -12374,6 +12408,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.21.0: {} undici@7.22.0: {} diff --git a/turbo.json b/turbo.json index fbebc80..bef9a86 100644 --- a/turbo.json +++ b/turbo.json @@ -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": {