fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass)
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 <cursoragent@cursor.com>
This commit is contained in:
parent
4a10dcedd3
commit
6dae7adc77
5 changed files with 126 additions and 22 deletions
69
lib/rate-limit.js
Normal file
69
lib/rate-limit.js
Normal file
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue