From 297afca1ae89147b6541ad176f90761367d7c16c Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Sat, 23 May 2026 10:51:33 -0500 Subject: [PATCH] =?UTF-8?q?fix(auth):=20tighten=20public=20auth=20surface?= =?UTF-8?q?=20=E2=80=94=20CORS=20+=20rate=20limit=20(Brief=204=20of=20fix-?= =?UTF-8?q?auth-bypass)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds rate limiting to /api/auth/login and /api/auth/register and removes their wide-open CORS allowlist. Rate limiting (@upstash/ratelimit + @upstash/redis): - 5 attempts per 15-minute sliding window per IP, prefix "tcgvault:auth" - new lib/rate-limit.js, lazy singleton, single source of truth - reads KV_REST_API_URL / KV_REST_API_TOKEN (Vercel Upstash Marketplace convention — auto-provisioned, no manual env-var setup needed) - fail-closed in production if env vars are missing (better to error one login than silently disable brute-force protection on live) - fail-open in dev/test if env vars are missing (single console.warn) - fail-open on Upstash backend outage (defense-in-depth — don't lock the entire userbase out if Upstash is down) - IP extracted from x-forwarded-for first hop, with socket fallback; NOT req.body.email (rotates) or Authorization header (absent on unauthenticated login) CORS: - Removed Access-Control-Allow-Origin: * + companion headers + OPTIONS preflight from login.js and register.js - These are first-party endpoints called from the same-origin SPA; the "*" allowlist was a development convenience that shipped to prod - verify.js is OUT OF SCOPE per architect's "cors-tighten" deferral (see convoy plan § Architect's calls) Other handler ordering preserved verbatim per brief: method gate first, then rate-limit check (returns 429 with Retry-After header), then the existing try/catch + body parsing + DB work. Pre-merge requirements: KV_REST_API_URL + KV_REST_API_TOKEN must be set in Vercel Production (already done — Upstash marketplace integration auto-provisioned both, confirmed by maintainer 2026-05-23). Convoy: fix-auth-bypass / Brief 4 Co-authored-by: Cursor --- lib/rate-limit.js | 69 ++++++++++++++++++++++++++++++++++++++ package-lock.json | 41 ++++++++++++++++++++++ package.json | 2 ++ pages/api/auth/login.js | 18 ++++------ pages/api/auth/register.js | 18 ++++------ 5 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 lib/rate-limit.js 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;