diff --git a/lib/rate-limit.js b/lib/rate-limit.js new file mode 100644 index 0000000..53b28c7 --- /dev/null +++ b/lib/rate-limit.js @@ -0,0 +1,69 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis'; + +// Lazy singleton. Module-load init would throw in environments without +// Upstash env vars (local dev pre-onboarding, tests that transitively +// import the auth handlers, Vercel build-time bundling). Defer construction +// until the first request actually arrives. +let cached = null; + +function init() { + // Env-var names match Vercel's Upstash Marketplace integration, which + // auto-provisions KV_REST_API_URL and KV_REST_API_TOKEN. See + // https://upstash.com/docs/redis/howto/vercelintegration. Single-source- + // of-truth — do NOT alias to UPSTASH_REDIS_REST_*. + const url = process.env.KV_REST_API_URL; + const token = process.env.KV_REST_API_TOKEN; + + if (url && token) { + const redis = new Redis({ url, token }); + const ratelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(5, '15 m'), + prefix: 'tcgvault:auth', + }); + return { mode: 'live', ratelimit }; + } + + if (process.env.NODE_ENV === 'production') { + // Fail-closed in production. A single failed login is a better outcome + // than silently disabling brute-force protection on the live site. + throw new Error( + '[rate-limit] Upstash not configured. Set KV_REST_API_URL and KV_REST_API_TOKEN in the deployment environment (auto-provisioned by the Vercel Upstash Marketplace integration) before serving auth traffic.' + ); + } + + console.warn( + '[rate-limit] KV_REST_API_URL / KV_REST_API_TOKEN not set — rate limiting disabled (dev/test only)' + ); + return { mode: 'noop' }; +} + +function extractIdentifier(req) { + const xff = req.headers?.['x-forwarded-for']; + const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim(); + return firstHop || req.socket?.remoteAddress || 'anonymous'; +} + +export async function checkAuthRateLimit(req) { + if (!cached) { + cached = init(); + } + + if (cached.mode === 'noop') { + return { allowed: true, remaining: Infinity, reset: 0 }; + } + + const identifier = extractIdentifier(req); + + try { + const { success, remaining, reset } = await cached.ratelimit.limit(identifier); + return { allowed: success, remaining, reset }; + } catch (err) { + // Fail-open on Upstash outage. A hard outage at the rate-limit backend + // should not lock the entire user base out of login. Brute-force + // protection lives behind defense-in-depth (Vercel firewall, etc.). + console.error('[rate-limit]', err); + return { allowed: true, remaining: Infinity, reset: 0 }; + } +} diff --git a/package-lock.json b/package-lock.json index 1ae678f..8d6b239 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@neondatabase/serverless": "^1.0.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/blob": "^1.1.1", "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.2", @@ -2025,6 +2027,39 @@ "win32" ] }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vercel/blob": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-1.1.1.tgz", @@ -7390,6 +7425,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "5.29.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", diff --git a/package.json b/package.json index 5640b79..9ab4f3a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ }, "dependencies": { "@neondatabase/serverless": "^1.0.1", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/blob": "^1.1.1", "@vercel/postgres": "^0.10.0", "bcryptjs": "^3.0.2", diff --git a/pages/api/auth/login.js b/pages/api/auth/login.js index fd135c8..ccb2964 100644 --- a/pages/api/auth/login.js +++ b/pages/api/auth/login.js @@ -1,23 +1,19 @@ import bcrypt from 'bcryptjs'; import { sql } from '@vercel/postgres'; import { generateToken } from '../auth-utils.js'; +import { checkAuthRateLimit } from '../../../lib/rate-limit.js'; export default async function handler(req, res) { - // Set CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } + const { allowed, reset } = await checkAuthRateLimit(req); + if (!allowed) { + res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000)); + return res.status(429).json({ error: 'Too many attempts. Try again later.' }); + } + try { const { email, password } = req.body; diff --git a/pages/api/auth/register.js b/pages/api/auth/register.js index 44bb475..c89f2e8 100644 --- a/pages/api/auth/register.js +++ b/pages/api/auth/register.js @@ -2,23 +2,19 @@ import bcrypt from 'bcryptjs'; import { sql } from '@vercel/postgres'; import { generateUniqueSlug } from '../../../lib/slug-utils.js'; import { generateToken } from '../auth-utils.js'; +import { checkAuthRateLimit } from '../../../lib/rate-limit.js'; export default async function handler(req, res) { - // Set CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - // Handle preflight requests - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } + const { allowed, reset } = await checkAuthRateLimit(req); + if (!allowed) { + res.setHeader('Retry-After', Math.ceil((reset - Date.now()) / 1000)); + return res.status(429).json({ error: 'Too many attempts. Try again later.' }); + } + try { const { email, password, firstName, lastName, username, profileImage } = req.body;