fix(auth): tighten public auth surface — CORS + rate limit (Brief 4 of fix-auth-bypass) #9

Merged
varutasu merged 1 commit from brief/fix-auth-bypass/4-tighten-auth-surface into main 2026-05-23 11:57:50 -04:00
5 changed files with 126 additions and 22 deletions
Showing only changes of commit 6dae7adc77 - Show all commits

69
lib/rate-limit.js Normal file
View 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
View file

@ -9,6 +9,8 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@neondatabase/serverless": "^1.0.1", "@neondatabase/serverless": "^1.0.1",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"@vercel/blob": "^1.1.1", "@vercel/blob": "^1.1.1",
"@vercel/postgres": "^0.10.0", "@vercel/postgres": "^0.10.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
@ -2025,6 +2027,39 @@
"win32" "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": { "node_modules/@vercel/blob": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@vercel/blob/-/blob-1.1.1.tgz",
@ -7390,6 +7425,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/undici": {
"version": "5.29.0", "version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",

View file

@ -14,6 +14,8 @@
}, },
"dependencies": { "dependencies": {
"@neondatabase/serverless": "^1.0.1", "@neondatabase/serverless": "^1.0.1",
"@upstash/ratelimit": "^2.0.8",
"@upstash/redis": "^1.38.0",
"@vercel/blob": "^1.1.1", "@vercel/blob": "^1.1.1",
"@vercel/postgres": "^0.10.0", "@vercel/postgres": "^0.10.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",

View file

@ -1,23 +1,19 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { sql } from '@vercel/postgres'; import { sql } from '@vercel/postgres';
import { generateToken } from '../auth-utils.js'; import { generateToken } from '../auth-utils.js';
import { checkAuthRateLimit } from '../../../lib/rate-limit.js';
export default async function handler(req, res) { 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') { if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' }); 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 { try {
const { email, password } = req.body; const { email, password } = req.body;

View file

@ -2,23 +2,19 @@ import bcrypt from 'bcryptjs';
import { sql } from '@vercel/postgres'; import { sql } from '@vercel/postgres';
import { generateUniqueSlug } from '../../../lib/slug-utils.js'; import { generateUniqueSlug } from '../../../lib/slug-utils.js';
import { generateToken } from '../auth-utils.js'; import { generateToken } from '../auth-utils.js';
import { checkAuthRateLimit } from '../../../lib/rate-limit.js';
export default async function handler(req, res) { 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') { if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' }); 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 { try {
const { email, password, firstName, lastName, username, profileImage } = req.body; const { email, password, firstName, lastName, username, profileImage } = req.body;