mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Both cookies were read straight into database columns after nothing more than a format check. httpOnly keeps JavaScript out of them and does nothing about curl, so the anonymous id was a string the caller picked: enough to write a first-touch row for a visitor who never existed, to file it under a channel of their choosing, and to claim that id's events at signup, since the backfill matches on the id alone. They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than node:crypto because the proxy runs on the edge and the pages that read the cookies back run in Node. The first-touch body moved to base64url on the way: cookie values are percent-encoded and decoded by several layers that do not agree on how many times, and a payload carrying its own percent escapes comes back subtly different and takes the signature with it. Signing stops a caller choosing an id, not collecting one, since dropping the cookie and asking for the landing page again mints another. So the bot and prefetch filters moved to where the rows are written rather than only where the cookies are issued, which also fixes a returning visitor's prefetch of /register recording a signup start, and a per-client hourly ceiling now sits in front of the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every caller resolves to 127.0.0.1 and the bucket would empty on real traffic long before it emptied on a flood. Four smaller things around it: - /api/events checked the flag and the origin after paying for a rate-limit write, so a host who never turned analytics on was still writing a row per anonymous POST. Both checks are free and now come first, and the limiter answers 204 rather than 429: a beacon has nobody to tell, and a flooder should not be handed the reset time. - /api/onboarding/source was keyed by IP on an authenticated route. Without TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and with it a shared office address locks out everyone after one colleague answered. Keyed by account, like /api/onboarding/complete beside it. - The cookies took their Secure flag from request.nextUrl.protocol, which behind a TLS-terminating reverse proxy is the container-internal http address. It comes off the configured public origin now. - sanitizeLandingPath took anything that started with a slash, including from the cookie, so a hand-written one could put newlines and markup into a column an admin table may render one day. Also: the paid-account query had no LIMIT and returned every active account's name and email, the growth route answered 403 where it meant 401, and the schema claimed no free text is stored when self_reported_note holds 200 characters of it.
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
import { createHash } from 'crypto';
|
|
import { db } from '@/lib/db';
|
|
import { NextResponse } from 'next/server';
|
|
import { logError, logWarn } from '@/lib/logger';
|
|
|
|
const RATE_LIMIT_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
|
|
const globalForRateLimitCleanup = globalThis as unknown as {
|
|
rateLimitCleanupIntervalStarted?: boolean;
|
|
};
|
|
|
|
interface RateLimitConfig {
|
|
windowMs: number; // Time window in milliseconds
|
|
maxRequests: number; // Max requests per window
|
|
}
|
|
|
|
interface RateLimitResult {
|
|
allowed: boolean;
|
|
remaining: number;
|
|
resetAt: Date;
|
|
}
|
|
|
|
const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
|
|
|
function isRateLimitDisabled(): boolean {
|
|
const rawValue = process.env.DISABLE_RATE_LIMIT?.trim().toLowerCase();
|
|
return rawValue !== undefined && TRUTHY_ENV_VALUES.has(rawValue);
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'production' && isRateLimitDisabled()) {
|
|
throw new Error(
|
|
'DISABLE_RATE_LIMIT must not be set in production. ' +
|
|
'Remove or unset the environment variable before deploying.'
|
|
);
|
|
}
|
|
|
|
// Without a proxy mode every caller resolves to 127.0.0.1, so the limiter counts the whole
|
|
// world in one bucket. That is the deliberate trade-off (trusting a spoofable header is
|
|
// worse), but a deployment behind a proxy should know it is running with global rather
|
|
// than per-client limits rather than discover it under load.
|
|
if (process.env.NODE_ENV === 'production' && !process.env.TRUSTED_PROXY_MODE?.trim()) {
|
|
logWarn(
|
|
'TRUSTED_PROXY_MODE is not set. Every request resolves to 127.0.0.1, so rate limits ' +
|
|
'apply per process rather than per client. Set TRUSTED_PROXY_MODE=cloudflare or ' +
|
|
'TRUSTED_PROXY_MODE=nginx once you have confirmed your proxy overwrites the ' +
|
|
'corresponding header on every inbound request.'
|
|
);
|
|
}
|
|
|
|
// Industry-standard rate limit defaults per action
|
|
export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
|
|
// Auth — strict to prevent brute force / credential stuffing
|
|
register: { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
login: { windowMs: 15 * 60 * 1000, maxRequests: 10 }, // 10 per 15 min
|
|
'share-unlock': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min per IP
|
|
'share-unlock-token': { windowMs: 15 * 60 * 1000, maxRequests: 8 }, // 8 per 15 min per IP+token
|
|
|
|
// Content creation — moderate limits
|
|
comment: { windowMs: 60 * 1000, maxRequests: 15 }, // 15 per minute
|
|
'image-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'voice-upload': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'feedback-submit': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
|
|
'feedback-upload': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'create-project': { windowMs: 60 * 60 * 1000, maxRequests: 20 }, // 20 per hour
|
|
'create-video': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'create-version': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'create-workspace': { windowMs: 60 * 60 * 1000, maxRequests: 10 }, // 10 per hour
|
|
'asset-list': { windowMs: 60 * 1000, maxRequests: 120 }, // 120 per minute
|
|
'asset-create': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'asset-delete': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
'asset-download': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'asset-bunny-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
'asset-r2-init': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
|
|
// Search — debounced on client but protect against scripted callers
|
|
search: { windowMs: 60 * 1000, maxRequests: 60 }, // 60 per minute
|
|
|
|
// Watch progress — allow frequent updates but prevent abuse
|
|
'watch-progress': { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute (pausing + periodic + visibility changes)
|
|
|
|
// Exports
|
|
'comment-export': { windowMs: 60 * 1000, maxRequests: 10 }, // 10 per minute
|
|
|
|
// Downloads — strict enough to limit upstream probing/cost abuse
|
|
'video-download': { windowMs: 60 * 1000, maxRequests: 8 }, // 8 per minute
|
|
'video-download-prepare': { windowMs: 60 * 1000, maxRequests: 5 }, // 5 per minute
|
|
'project-download': { windowMs: 60 * 1000, maxRequests: 3 }, // 3 per minute
|
|
|
|
// Email verification
|
|
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
|
|
'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
|
|
// Onboarding — one-time action, very strict. Both are keyed by user id, not IP:
|
|
// an office behind one address must not be able to lock its colleagues out of
|
|
// finishing onboarding.
|
|
'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
'onboarding-source': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
|
|
|
|
// Member management
|
|
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
|
// Unauthenticated invitation preview (accept landing + invited sign-up). Two buckets,
|
|
// as with share-unlock: a generous per-IP one that bounds token enumeration, and a
|
|
// tight per-IP+token one that stops repeated probing of a single invitation.
|
|
'invitation-preview': { windowMs: 15 * 60 * 1000, maxRequests: 120 }, // 120 per 15 min per IP
|
|
'invitation-preview-token': { windowMs: 15 * 60 * 1000, maxRequests: 12 }, // 12 per 15 min per IP+token
|
|
'manage-member': { windowMs: 60 * 1000, maxRequests: 20 }, // 20 per minute
|
|
|
|
// Mutations (update/delete) — moderate
|
|
mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
|
|
|
|
// Analytics beacon — anonymous and public, so bound it per IP
|
|
'analytics-beacon': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
|
|
|
|
// Anonymous visitor events recorded server-side from the landing pages. Bounds
|
|
// a flood that would otherwise write two rows per request forever, and is
|
|
// deliberately generous: these are the denominator of every rate on the
|
|
// scoreboard, so a limit that bites real traffic costs more than the flood it
|
|
// stops. Only applied when the client IP is real — see isClientIpTrustworthy.
|
|
'analytics-visitor': { windowMs: 60 * 60 * 1000, maxRequests: 240 }, // 240 per hour
|
|
|
|
// General reads — generous
|
|
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
|
|
};
|
|
|
|
// Column widths of rate_limits.key and rate_limits.action in prisma/schema.prisma. A value
|
|
// wider than its column would fail the INSERT with SQLSTATE 22001.
|
|
const RATE_LIMIT_KEY_MAX_LENGTH = 255;
|
|
const RATE_LIMIT_ACTION_MAX_LENGTH = 50;
|
|
|
|
/**
|
|
* Fits a value to its column without ever giving up on counting it. A SHA-256 hex digest
|
|
* is 64 characters, so it is truncated for the narrower action column; 50 hex characters
|
|
* is 200 bits, far past any collision that matters for a rate limit bucket.
|
|
*/
|
|
function fitToColumn(value: string, maxLength: number): string {
|
|
if (value.length <= maxLength) return value;
|
|
return createHash('sha256').update(value).digest('hex').slice(0, maxLength);
|
|
}
|
|
|
|
/**
|
|
* Check and update rate limit for a given key and action
|
|
* Uses PostgreSQL UNLOGGED table for performance
|
|
*/
|
|
export async function checkRateLimit(
|
|
key: string,
|
|
action: string,
|
|
config?: RateLimitConfig
|
|
): Promise<RateLimitResult> {
|
|
const { windowMs, maxRequests } = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
|
|
|
|
if (isRateLimitDisabled()) {
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests,
|
|
resetAt: new Date(Date.now() + windowMs),
|
|
};
|
|
}
|
|
|
|
const windowSeconds = Math.floor(windowMs / 1000);
|
|
|
|
// Anything wider than its column is replaced by a digest rather than skipped. Skipping
|
|
// meant the limit stopped applying altogether, and letting the value through meant the
|
|
// INSERT failed with SQLSTATE 22001 and the catch below allowed the request anyway.
|
|
// Both were fail-open. A digest is stable, so the same caller keeps the same bucket.
|
|
const storedKey = fitToColumn(key, RATE_LIMIT_KEY_MAX_LENGTH);
|
|
const storedAction = fitToColumn(action, RATE_LIMIT_ACTION_MAX_LENGTH);
|
|
|
|
try {
|
|
// Atomic upsert with window check
|
|
// If window expired, reset count; otherwise increment
|
|
const result = await db.$queryRaw<
|
|
Array<{
|
|
count: number;
|
|
window_start: Date;
|
|
is_new_window: boolean;
|
|
}>
|
|
>`
|
|
INSERT INTO rate_limits (key, action, count, window_start)
|
|
VALUES (${storedKey}, ${storedAction}, 1, NOW())
|
|
ON CONFLICT (key, action) DO UPDATE SET
|
|
count = CASE
|
|
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
|
THEN 1
|
|
ELSE rate_limits.count + 1
|
|
END,
|
|
window_start = CASE
|
|
WHEN rate_limits.window_start < NOW() - (${windowSeconds} || ' seconds')::INTERVAL
|
|
THEN NOW()
|
|
ELSE rate_limits.window_start
|
|
END
|
|
RETURNING count, window_start,
|
|
(window_start = NOW()) as is_new_window
|
|
`;
|
|
|
|
const record = result[0];
|
|
const resetAt = new Date(record.window_start.getTime() + windowMs);
|
|
const remaining = Math.max(0, maxRequests - record.count);
|
|
const allowed = record.count <= maxRequests;
|
|
|
|
return { allowed, remaining, resetAt };
|
|
} catch (error) {
|
|
// If table doesn't exist, allow the request but log warning
|
|
logError('Rate limit check failed (table may not exist):', error);
|
|
return {
|
|
allowed: true,
|
|
remaining: maxRequests,
|
|
resetAt: new Date(Date.now() + windowMs),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Basic IP format validation — IPv4 or IPv6 (loose check, rejects obvious garbage)
|
|
const IP_PATTERN = /^[\da-fA-F.:]+$/;
|
|
|
|
function isPlausibleIp(value: string): boolean {
|
|
return value.length <= 45 && IP_PATTERN.test(value);
|
|
}
|
|
|
|
/**
|
|
* Get client IP from request headers.
|
|
*
|
|
* Trusting proxy-injected headers is only safe when a known trusted proxy sits in front
|
|
* of this server and strips or overwrites those headers before forwarding requests.
|
|
* Set TRUSTED_PROXY_MODE to opt in:
|
|
*
|
|
* TRUSTED_PROXY_MODE=cloudflare — trust cf-connecting-ip (Cloudflare edge)
|
|
* TRUSTED_PROXY_MODE=nginx — trust x-real-ip / x-forwarded-for (Nginx real_ip_header)
|
|
*
|
|
* Without TRUSTED_PROXY_MODE set, no proxy headers are trusted: all requests appear
|
|
* as 127.0.0.1, which means rate limits apply per-process rather than per-client IP.
|
|
* In that configuration, prefer session/user-keyed rate limits for authenticated endpoints.
|
|
*
|
|
* WARNING: Do not set TRUSTED_PROXY_MODE unless you have confirmed that your proxy
|
|
* strips or overwrites the corresponding headers on every inbound request. Failing to
|
|
* do so allows clients to spoof their IP and bypass rate limits.
|
|
*/
|
|
export function getClientIp(request: Request): string {
|
|
return getClientIpFromHeaders(request.headers);
|
|
}
|
|
|
|
/**
|
|
* Same resolution as {@link getClientIp}, for callers that only have headers rather than a
|
|
* Request — server components reading `await headers()`.
|
|
*/
|
|
export function getClientIpFromHeaders(headers: Headers): string {
|
|
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
|
|
|
|
if (mode === 'cloudflare') {
|
|
// cf-connecting-ip is injected by Cloudflare and cannot be set by clients
|
|
// when origin access is restricted to Cloudflare's IP ranges.
|
|
const cfIp = headers.get('cf-connecting-ip');
|
|
if (cfIp && isPlausibleIp(cfIp)) {
|
|
return cfIp;
|
|
}
|
|
}
|
|
|
|
if (mode === 'nginx') {
|
|
// x-real-ip is set by Nginx's real_ip_header directive (connection-level, not spoofable
|
|
// by clients when set_real_ip_from is configured for the upstream proxy).
|
|
const realIp = headers.get('x-real-ip');
|
|
if (realIp && isPlausibleIp(realIp)) return realIp;
|
|
|
|
// x-forwarded-for last entry added by Nginx when proxy_add_x_forwarded_for is used.
|
|
const forwardedFor = headers.get('x-forwarded-for');
|
|
if (forwardedFor) {
|
|
const entries = forwardedFor.split(',');
|
|
const last = entries[entries.length - 1].trim();
|
|
if (isPlausibleIp(last)) return last;
|
|
}
|
|
}
|
|
|
|
// No trusted proxy configured — fall back to a constant value.
|
|
// Rate limiting will apply per-process; use userId-keyed limits for authenticated endpoints.
|
|
return '127.0.0.1';
|
|
}
|
|
|
|
/**
|
|
* Whether {@link getClientIp} resolves to the caller rather than to 127.0.0.1.
|
|
*
|
|
* Without TRUSTED_PROXY_MODE every request shares one bucket. That is a usable
|
|
* global brake on an endpoint nobody hits in a loop, and useless on a landing
|
|
* page: the bucket would empty on real traffic long before it emptied on an
|
|
* attacker, and the counting this whole subsystem exists for would stop. Callers
|
|
* that only make sense per-client check this first.
|
|
*/
|
|
export function isClientIpTrustworthy(): boolean {
|
|
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
|
|
return mode === 'cloudflare' || mode === 'nginx';
|
|
}
|
|
|
|
/**
|
|
* Create rate limit headers for response
|
|
*/
|
|
export function rateLimitHeaders(result: RateLimitResult, maxRequests: number): HeadersInit {
|
|
return {
|
|
'X-RateLimit-Limit': maxRequests.toString(),
|
|
'X-RateLimit-Remaining': result.remaining.toString(),
|
|
'X-RateLimit-Reset': Math.floor(result.resetAt.getTime() / 1000).toString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cleanup old rate limit entries (call periodically)
|
|
*/
|
|
export async function cleanupRateLimits(): Promise<void> {
|
|
try {
|
|
await db.$executeRaw`SELECT cleanup_rate_limits()`;
|
|
} catch (error) {
|
|
logError('Rate limit cleanup failed:', error);
|
|
}
|
|
}
|
|
|
|
// Start cleanup interval once per process to avoid duplicate scheduling on module reload.
|
|
if (
|
|
!globalForRateLimitCleanup.rateLimitCleanupIntervalStarted &&
|
|
typeof setInterval !== 'undefined'
|
|
) {
|
|
const interval = setInterval(() => {
|
|
cleanupRateLimits().catch((err) => logError('Unexpected error:', err));
|
|
}, RATE_LIMIT_CLEANUP_INTERVAL_MS);
|
|
|
|
// Avoid keeping Node.js process alive because of housekeeping timers.
|
|
interval.unref?.();
|
|
globalForRateLimitCleanup.rateLimitCleanupIntervalStarted = true;
|
|
}
|
|
|
|
/**
|
|
* One-call rate limit check that returns a 429 NextResponse if blocked, or null if allowed.
|
|
* Use at the top of any API handler:
|
|
* const limited = await rateLimit(request, 'comment');
|
|
* if (limited) return limited;
|
|
*/
|
|
export async function rateLimit(
|
|
request: Request,
|
|
action: string,
|
|
config?: RateLimitConfig
|
|
): Promise<NextResponse | null> {
|
|
const ip = getClientIp(request);
|
|
const cfg = config || RATE_LIMIT_CONFIGS[action] || RATE_LIMIT_CONFIGS.api;
|
|
const result = await checkRateLimit(ip, action, cfg);
|
|
|
|
if (!result.allowed) {
|
|
return NextResponse.json(
|
|
{ error: 'Too many requests. Please try again later.' },
|
|
{
|
|
status: 429,
|
|
headers: rateLimitHeaders(result, cfg.maxRequests),
|
|
}
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|