Files
anouma/lib/auth/rateLimit.ts
T
maro 82c4261a81 Fix security and correctness issues found in full-codebase audit
- Restrict meetingPassword field so it isn't leaked via the public REST/GraphQL API
- Fix booking slot computation to use Europe/Berlin time instead of the server's UTC clock
- Reset email verification when a customer changes their email address
- Fix ICS calendar injection via unescaped carriage returns in event descriptions
- Bind WebRTC meeting kicks to a persistent client id so they survive a rejoin
- Move the meeting signaling token out of the WebSocket query string into the first message
- Prevent duplicate signaling connections from leaking when the same participant reconnects
- Use the rightmost X-Forwarded-For entry for rate limiting (single trusted reverse proxy)
- Use constant-time comparison for the cron endpoint's bearer token
- Remove SVG from allowed Media upload types (stored-XSS risk, unused anyway)
- Add baseline security headers and a CSP to next.config.ts
- Fix CI/CD script injection via unsanitized git ref names in release.yml
2026-09-06 21:59:39 +02:00

51 lines
2.0 KiB
TypeScript

/**
* Minimal in-memory fixed-window rate limiter for a single Node process
* (this app always runs as one process — see server.ts). Good enough to
* blunt abuse of the email-verification resend endpoint without adding an
* external store; resets on deploy/restart, which is an acceptable
* trade-off for this use case.
*/
const buckets = new Map<string, { count: number; resetAt: number }>();
// Opportunistic cleanup so long-running processes don't accumulate an
// unbounded number of stale keys (one per distinct IP/email ever seen).
const MAX_TRACKED_KEYS = 5000;
function sweepExpired(now: number) {
for (const [key, bucket] of buckets) {
if (bucket.resetAt < now) buckets.delete(key);
}
}
export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean {
const now = Date.now();
if (buckets.size > MAX_TRACKED_KEYS) sweepExpired(now);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt < now) {
buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
return true;
}
if (bucket.count >= opts.max) return false;
bucket.count += 1;
return true;
}
export function getClientIp(request: Request): string {
// Deployment is always behind exactly one trusted reverse proxy (see
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
// *appends* the real client IP to any X-Forwarded-For it already
// received rather than replacing it, so a directly-connecting client
// could prepend arbitrary spoofed entries of their own. Only the
// right-most (last) entry — the one the trusted proxy itself appended —
// is trustworthy; taking the first entry would let a client bypass this
// rate limit entirely by sending a fake X-Forwarded-For header.
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const parts = forwarded.split(",");
return parts[parts.length - 1]!.trim();
}
return "unknown";
}