Delete the public /api/config/gemini endpoint and remove client auto-load paths so GEMINI_AI_API_KEY stays server-side only. Add a scan rate-limit class for the upcoming server-side identify route and a CI gate that blocks reintroducing config key leaks or new browser LLM URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
136 lines
4.9 KiB
JavaScript
136 lines
4.9 KiB
JavaScript
import { Ratelimit } from '@upstash/ratelimit';
|
|
import { Redis } from '@upstash/redis';
|
|
|
|
// Per-class limiter configuration. Distinct Redis prefix per class is
|
|
// REQUIRED — without it, a search-class hit would consume the auth-class
|
|
// budget for the same identifier. `slidingWindow` chosen across all
|
|
// classes to match Brief 4's existing algorithm; switching to
|
|
// `tokenBucket` per-class would be its own convoy.
|
|
const LIMITER_CONFIG = {
|
|
auth: { limit: 5, window: '15 m', prefix: 'deckhearth:auth' },
|
|
search: { limit: 60, window: '1 m', prefix: 'deckhearth:search' },
|
|
upload: { limit: 10, window: '1 h', prefix: 'deckhearth:upload' },
|
|
generate: { limit: 5, window: '1 h', prefix: 'deckhearth:generate' },
|
|
import: { limit: 5, window: '1 h', prefix: 'deckhearth:import' },
|
|
scan: { limit: 5, window: '1 m', prefix: 'deckhearth:scan' },
|
|
};
|
|
|
|
// 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 instances = new Map();
|
|
for (const [name, cfg] of Object.entries(LIMITER_CONFIG)) {
|
|
instances.set(
|
|
name,
|
|
new Ratelimit({
|
|
redis,
|
|
limiter: Ratelimit.slidingWindow(cfg.limit, cfg.window),
|
|
prefix: cfg.prefix,
|
|
})
|
|
);
|
|
}
|
|
return { mode: 'live', instances };
|
|
}
|
|
|
|
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 extractIpIdentifier(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';
|
|
}
|
|
|
|
// THROWS on missing userId. Per-user limiters MUST sit AFTER the auth
|
|
// check in the handler body — silently falling back to IP here would
|
|
// convert a per-user limit into a per-IP limit, locking out other
|
|
// household members for one user's behavior. The throw surfaces the
|
|
// misordering immediately during development rather than at first
|
|
// production incident.
|
|
function extractUserIdentifier(userId) {
|
|
if (
|
|
userId === null ||
|
|
userId === undefined ||
|
|
userId === '' ||
|
|
(typeof userId === 'number' && Number.isNaN(userId))
|
|
) {
|
|
throw new Error(
|
|
'[rate-limit] extractUserIdentifier called without an authenticated userId. Place the rate-limit gate AFTER the auth check, never before.'
|
|
);
|
|
}
|
|
return `user:${userId}`;
|
|
}
|
|
|
|
async function check(className, identifier) {
|
|
if (!cached) {
|
|
cached = init();
|
|
}
|
|
|
|
if (cached.mode === 'noop') {
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
|
}
|
|
|
|
const limiter = cached.instances.get(className);
|
|
if (!limiter) {
|
|
throw new Error(`[rate-limit] Unknown limiter class: ${className}`);
|
|
}
|
|
|
|
try {
|
|
const { success, remaining, reset } = await limiter.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. Brute-force protection
|
|
// lives behind defense-in-depth (Vercel firewall, etc.).
|
|
console.error('[rate-limit]', err);
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
|
}
|
|
}
|
|
|
|
export async function checkAuthRateLimit(req) {
|
|
return check('auth', extractIpIdentifier(req));
|
|
}
|
|
|
|
export async function checkSearchRateLimit(req) {
|
|
return check('search', extractIpIdentifier(req));
|
|
}
|
|
|
|
export async function checkUploadRateLimit(req, userId) {
|
|
return check('upload', extractUserIdentifier(userId));
|
|
}
|
|
|
|
export async function checkGenerateRateLimit(req, userId) {
|
|
return check('generate', extractUserIdentifier(userId));
|
|
}
|
|
|
|
export async function checkImportRateLimit(req, userId) {
|
|
return check('import', extractUserIdentifier(userId));
|
|
}
|
|
|
|
export async function checkScanRateLimit(req, userId) {
|
|
return check('scan', extractUserIdentifier(userId));
|
|
}
|