Files
proof-of-work/proof-of-work/lib/rateLimit.ts
T
Keysat 3f22ef7600
CI / proof-of-work (Next.js app) (push) Has been cancelled
CI / start9/0.4 (StartOS package code) (push) Has been cancelled
v1.1.0:9 — P2 hardening: input-validation 400s, auth rate-limit, XFF anti-spoof, non-root container
P2 batch from the 2026-06-13 full-eval (EVALUATION.md / ROADMAP.md), reviewed by the reviewer agent. App-code + packaging only; no schema or data change, existing /data untouched.

Input validation: malformed JSON bodies, invalid date, and out-of-range or non-numeric pagination on /api/workouts now return 400 instead of 500. New lib/http.ts readJsonBody maps a bad body to a ZodError across the 11 CRUD routes whose catch maps ZodError to 400; me/import and admin/signups guard request.json() in an explicit try/catch.

Rate limiting: POST /api/auth now shares the UI login server action's per-IP 10-per-15min cap and returns 429 + Retry-After. clientIpFromHeaders reads the rightmost (trusted-proxy-appended) X-Forwarded-For entry instead of the spoofable leftmost.

Container: drops root. The entrypoint prepares /data as root, chowns it to nextjs, then exec su-exec nextjs:nodejs node server.js (su-exec added to the runner image). The container drop needs live sideload verification.
2026-06-13 00:03:47 -05:00

81 lines
2.8 KiB
TypeScript

/**
* Tiny in-process sliding-window rate limiter.
*
* Per Node process — fine for a single Next.js standalone server, which is
* the only deployment shape we ship. If the app ever runs behind multiple
* replicas, swap the Map for a shared Redis-backed bucket; the public API
* here stays the same.
*
* No deps: a Map of `key -> sorted timestamps[]`. Each call drops
* timestamps older than the window, then either records the new request
* and returns ok, or returns blocked with retry-after seconds.
*
* Memory: O(active-keys * limit). At a 5-per-15min ceiling and even
* thousands of distinct IPs, this is trivially small.
*/
interface Window {
/** Max events allowed per `windowMs` per `key`. */
limit: number;
/** Window length in ms. */
windowMs: number;
}
const buckets = new Map<string, number[]>();
export function rateLimit(
key: string,
{ limit, windowMs }: Window,
): { ok: true } | { ok: false; retryAfterSec: number } {
const now = Date.now();
const cutoff = now - windowMs;
const hits = (buckets.get(key) ?? []).filter((t) => t > cutoff);
if (hits.length >= limit) {
const oldest = hits[0];
const retryAfterSec = Math.max(1, Math.ceil((oldest + windowMs - now) / 1000));
buckets.set(key, hits);
return { ok: false, retryAfterSec };
}
hits.push(now);
buckets.set(key, hits);
return { ok: true };
}
/**
* Best-effort client IP extraction for rate-limit keys.
*
* `X-Forwarded-For` is a client-appendable, comma-separated list: each proxy
* APPENDS the address it observed. A direct client can therefore forge any
* number of leftmost entries — using `xff.split(',')[0]` (the leftmost) lets
* an attacker rotate a fake IP per request and defeat the limiter entirely.
*
* In a StartOS deployment the Next.js server sits behind exactly one trusted
* proxy hop, so the RIGHTMOST entry is the address that proxy actually saw —
* the only value the client cannot spoof. We key off that. (If the proxy
* overwrites rather than appends XFF, the list has a single entry and
* rightmost == leftmost, so this is also correct in that case.) If XFF is
* absent (direct access in dev), fall back to `x-real-ip`, then to the
* literal "unknown" key so the limiter still applies as a global cap.
*
* Assumes a single trusted hop; if the deployment ever grows additional
* trusted proxies, count that many entries in from the right instead.
*/
export function clientIpFromHeaders(headers: Headers): string {
const xff = headers.get('x-forwarded-for');
if (xff) {
const parts = xff
.split(',')
.map((p) => p.trim())
.filter(Boolean);
if (parts.length > 0) return parts[parts.length - 1];
}
const real = headers.get('x-real-ip');
if (real) {
const trimmed = real.trim();
if (trimmed) return trimmed;
}
return 'unknown';
}