3f22ef7600
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.
81 lines
2.8 KiB
TypeScript
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';
|
|
}
|