Files
HealthLog/src/lib/rate-limit.ts
T
Marc-André Bombeck 39d23dbb7d fix(api): meter every authenticated write where the caller is resolved
169 of 364 mutating handlers met no rate limit, every PATCH and DELETE on
an [id] among them. The shared per-account write bucket (300 a minute,
keyed on the acting account) was called by hand in a dozen creates; it is
now charged by the identity resolvers for any POST, PUT, PATCH or DELETE,
once per request, so a route is metered by resolving its caller.

The bulk ingest routes the native client syncs through in bursts
(measurements, workouts, intake, mood and cycle bulk, nutrients, the ECG
sweep) declare writeBucket: "own" and keep their dedicated ceilings, so a
large HealthKit backfill cannot lock the person out of logging a dose. A
read-over-POST stays free.

write-rate-limit-guard fails on any mutating route that neither resolves
its caller through a metering resolver nor calls a limiter, unless it is
on a short reasoned exception list; an opt-out must be on its own reviewed
list and call its own limiter.
2026-10-20 13:53:54 +02:00

339 lines
13 KiB
TypeScript

/**
* PostgreSQL-based sliding window rate limiter.
* Uses atomic upsert via raw SQL for correctness across multiple instances.
*/
import { prisma } from "@/lib/db";
import { getClientIpOrTrustWarning } from "@/lib/api-response";
import {
captureRateLimitResult,
rateLimitResponseHeaders,
type RateLimitSnapshot,
} from "@/lib/rate-limit-context";
export interface RateLimitResult extends RateLimitSnapshot {
allowed: boolean;
}
/**
* Check rate limit for a given key.
* Uses a single atomic SQL upsert — safe for concurrent requests.
* @param key Unique identifier (e.g., "auth:login:1.2.3.4")
* @param limit Max requests per window
* @param windowMs Window duration in milliseconds
*/
export async function checkRateLimit(
key: string,
limit: number,
windowMs: number,
): Promise<RateLimitResult> {
const windowInterval = `${windowMs} milliseconds`;
const rows = await prisma.$queryRaw<{ count: number; reset_at: Date }[]>`
INSERT INTO rate_limits (key, count, reset_at)
VALUES (${key}, 1, (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval)
ON CONFLICT (key) DO UPDATE SET
count = CASE
WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN 1
ELSE rate_limits.count + 1
END,
reset_at = CASE
WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval
ELSE rate_limits.reset_at
END
RETURNING count, reset_at
`;
const row = rows[0];
const resetAt = row.reset_at.getTime();
const count = Number(row.count);
const result: RateLimitResult = {
allowed: count <= limit,
limit,
remaining: Math.max(0, limit - count),
resetAt,
};
// Hand the verdict to the request-scoped cell so `apiHandler` can dress a
// bare 429 on the way out. A no-op outside a request.
captureRateLimitResult(result);
return result;
}
/**
* Give one slot back to a bucket whose charge turned out to be premature.
*
* A caller that charges the bucket up front (to keep the 429 cheap) but then
* fails BEFORE doing the metered work refunds the slot so the user's allowance
* reflects work actually performed, not attempts. Scoped to the live window:
* once `reset_at` has passed the counter is stale and a decrement would bleed
* into the NEXT window's budget, so an expired row is left untouched. Never
* drops below zero (concurrent refunds after a window flip are a no-op).
*
* Known slack: if the charge's window expires and a new request opens the
* NEXT window before a late refund lands, the refund decrements that fresh
* window instead — at most one extra slot there, only on failure paths that
* never reached the metered work. Accepted; carrying the charge's `reset_at`
* through every failure path is not worth one slot of slack.
*/
export async function refundRateLimit(key: string): Promise<void> {
await prisma.$executeRaw`
UPDATE rate_limits
SET count = GREATEST(count - 1, 0)
WHERE key = ${key} AND reset_at >= (NOW() AT TIME ZONE 'UTC')
`;
}
/**
* Rate limit response headers: `Retry-After` plus the `X-RateLimit-*` triple.
* See `rateLimitResponseHeaders` for what each one promises.
*/
export function rateLimitHeaders(
result: RateLimitSnapshot,
): Record<string, string> {
return rateLimitResponseHeaders(result);
}
/**
* v1.15.20 — shared per-user budget for the heavy read-only analytics
* surfaces (`/api/insights/comprehensive`, `/api/insights/correlations`,
* `/api/insights/derived`, `/api/analytics/range`). The insights page
* legitimately fans several of these out per mount, so the bucket is
* generous — 120/min only caps a runaway client loop or a scripted
* drain, mirroring the `sync:changes` budget. One shared bucket (not
* per-surface) keeps the cap meaningful when a loop rotates across
* endpoints.
*/
const ANALYTICS_READ_LIMIT = 120;
const ANALYTICS_READ_WINDOW_MS = 60 * 1000;
export async function checkAnalyticsReadRateLimit(
userId: string,
): Promise<RateLimitResult> {
return checkRateLimit(
`analytics-read:${userId}`,
ANALYTICS_READ_LIMIT,
ANALYTICS_READ_WINDOW_MS,
);
}
/**
* Environment backfill: three a rolling hour per account. Each one can ask
* the shared Open-Meteo budget for a two-year range, so the general
* analytics ceiling (120 a minute) is far too loose for it.
*/
export const ENVIRONMENT_BACKFILL_LIMIT = 3;
const ENVIRONMENT_BACKFILL_WINDOW_MS = 60 * 60 * 1000;
export async function checkEnvironmentBackfillRateLimit(
userId: string,
): Promise<RateLimitResult> {
return checkRateLimit(
`environment-backfill:${userId}`,
ENVIRONMENT_BACKFILL_LIMIT,
ENVIRONMENT_BACKFILL_WINDOW_MS,
);
}
/**
* Shared per-account ceiling for every authenticated write.
*
* The batch endpoints have been capped at 60 calls a minute since they were
* written; the per-record siblings a naive or older client loops over were not
* capped at all, which is backwards — the batch endpoint is the one a
* well-behaved client uses. This was first the floor under a dozen creates,
* called by hand; it is now charged where the caller is resolved
* (`meterAccountWrite`, `src/lib/auth/write-meter.ts`), so every POST, PUT,
* PATCH and DELETE an authenticated caller sends counts against it, except on
* the bulk ingest routes that meter themselves.
*
* One shared bucket, not one per route, for the reason
* `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten routes
* should still meet a cap. Three hundred a minute is five writes a second
* sustained for a full minute — no hand-driven session and no sync burst
* reaches it. It exists to cut off the pathological loop, not to shape
* traffic.
*
* A refused request is counted too: the check runs before the body is read,
* so a client looping on a 422 spends its allowance without writing a row.
*
* Keyed on the ACTING account rather than the record, the frozen precedent from
* the delegable medication routes: a delegate burns their own allowance rather
* than locking the record's owner out of their own log, and cannot collect a
* fresh one by switching records.
*/
export const RECORD_WRITE_BUCKET_PREFIX = "record-write";
export const RECORD_WRITE_LIMIT = 300;
export const RECORD_WRITE_WINDOW_MS = 60 * 1000;
export async function checkRecordWriteRateLimit(
accountId: string,
): Promise<RateLimitResult> {
return checkRateLimit(
`${RECORD_WRITE_BUCKET_PREFIX}:${accountId}`,
RECORD_WRITE_LIMIT,
RECORD_WRITE_WINDOW_MS,
);
}
/**
* Per-account ceiling for the profile email-address change.
*
* Changing the address means asking whether another account already holds
* it, and the answer to that question is 409 versus 200 — an existence check
* over every address on the instance, available to any signed-in caller. The
* two routes that reach it (`PUT /api/auth/profile`, `PATCH
* /api/user/profile`) had no ceiling at all and `apiHandler` supplies none,
* so a single account could walk an address book at whatever rate it liked.
*
* Keyed on the acting account rather than on the address, the frozen
* precedent from the record writes: an attacker cannot collect a fresh
* allowance by switching the address they probe, and one account's sweep
* cannot lock a stranger out of their own settings page.
*
* Ten an hour is far above honest use — an address is changed once in a
* while, not ten times a day — and it is only charged when the submitted
* address actually differs from the one on file, so re-saving a settings
* form that carries the unchanged address never spends the budget. What it
* ends is the unbounded sweep: two hundred and forty questions a day per
* account instead of as many as the network will carry. Both answers worth
* detecting write an audit row of their own — `profile.email.conflict` when
* an address turns out to be taken, `profile.email.rate_limited` when the
* budget runs out. The ordinary `profile.update` row is written after the
* save, which neither of those paths reaches.
*/
export const PROFILE_EMAIL_BUCKET_PREFIX = "profile-email";
export const PROFILE_EMAIL_LIMIT = 10;
export const PROFILE_EMAIL_WINDOW_MS = 60 * 60 * 1000;
export async function checkProfileEmailRateLimit(
accountId: string,
): Promise<RateLimitResult> {
return checkRateLimit(
`${PROFILE_EMAIL_BUCKET_PREFIX}:${accountId}`,
PROFILE_EMAIL_LIMIT,
PROFILE_EMAIL_WINDOW_MS,
);
}
/**
* v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`).
* The bucket is keyed by the `<userId>:<tokenId>` binding the Bearer
* resolver surfaces — NOT the user alone — so a single leaked / shared
* token cannot exhaust the budget for the account's other tokens, and the
* cap is scoped to the exact credential in use (REQ-SEC-9, REQ-SEC-11).
* An MCP client legitimately fans several tool calls out per turn, so the
* window is generous; it only caps a runaway loop or a scripted drain.
*/
const MCP_LIMIT = 120;
const MCP_WINDOW_MS = 60 * 1000;
export async function checkMcpRateLimit(
binding: string,
): Promise<RateLimitResult> {
return checkRateLimit(`mcp:${binding}`, MCP_LIMIT, MCP_WINDOW_MS);
}
/**
* v1.22.0 — tighter per-credential ceiling for the remote MCP WRITE surface
* (the `log_measurement` / `log_mood` tools), applied ON TOP of the general
* `/mcp` per-binding request limiter. Keyed by the same `<userId>:<tokenId>`
* binding so a single leaked / shared write-scoped token cannot flood writes
* into the account, and the cap is scoped to the exact credential in use.
* Writes are deliberate, low-frequency events, so this is far tighter than the
* read budget — 60 confirmed writes per 5 minutes only caps a runaway loop.
*/
const MCP_WRITE_LIMIT = 60;
const MCP_WRITE_WINDOW_MS = 5 * 60 * 1000;
export async function checkMcpWriteRateLimit(
binding: string,
): Promise<RateLimitResult> {
return checkRateLimit(
`mcp-write:${binding}`,
MCP_WRITE_LIMIT,
MCP_WRITE_WINDOW_MS,
);
}
/**
* v1.16.16 — per-user ceiling for the AI-consent surfaces
* (`POST /api/consent/ai`, `POST /api/consent/ai/web`,
* `GET/DELETE /api/consent/ai/latest`). The web client calls the heal
* endpoint on every AI-settings mount and the iOS toggle hits grant/revoke
* per flip, so the bucket is generous — 20/min only caps a scripted loop or
* a stolen session pinning the receipts table, mirroring the coach-chat
* ceiling. One shared bucket keyed by `userId` (not per-route) keeps the cap
* meaningful when a loop rotates across the four endpoints.
*/
const CONSENT_LIMIT = 20;
const CONSENT_WINDOW_MS = 60 * 1000;
export async function checkConsentRateLimit(
userId: string,
): Promise<RateLimitResult> {
return checkRateLimit(`consent:${userId}`, CONSENT_LIMIT, CONSENT_WINDOW_MS);
}
/**
* v1.4.43 — anonymous/trust-violation bucket key shared by every
* caller of `checkAuthSurfaceRateLimit`. When `TRUST_PROXY_HOPS` and the
* actual proxy chain don't agree, `getClientIp` returns null and every
* caller falls back to a literal `"unknown"` rate-limit bucket. Any
* attacker can then exhaust the `auth:login:unknown` bucket and lock
* every other legitimate anonymous caller out of the surface.
*
* The tighter bucket is shared across every auth surface — a misconfigured
* deployment with the trust chain broken accepts at most this many
* anonymous attempts across login + register + passkey-verify + … per
* window. Operators see the once-per-process `console.warn` from
* `getClientIpOrTrustWarning` and fix the proxy chain; the tight bucket
* caps the blast radius while they do.
*/
const TIGHT_ANON_KEY = "auth:anon:trust-violation";
const TIGHT_ANON_LIMIT = 100;
const TIGHT_ANON_WINDOW_MS = 15 * 60 * 1000;
/**
* Rate-limit helper for anonymous auth surfaces (login, register,
* passkey-login-{options,verify}, refresh).
*
* Per-IP path: when the trust chain resolves cleanly, route the request
* to a bucket keyed by `{prefix}:{ip ?? "unknown"}` with the caller's
* supplied limit + window. Existing semantics, byte-for-byte.
*
* Tightened path: when `getClientIpOrTrustWarning` reports
* `trustViolation === true`, the configured proxy hops and the actual
* chain length don't match. Every anonymous caller already shares a
* single `"unknown"` bucket on the per-IP path; this helper instead
* routes them to a fixed global bucket with a strict 100/15min cap.
* One attacker can no longer exhaust the per-surface bucket — they hit
* the global cap first, and every other anonymous caller on any auth
* surface shares the same cap.
*
* The returned shape matches `checkRateLimit` so call sites can attach
* `rateLimitHeaders(rl)` unchanged.
*/
export async function checkAuthSurfaceRateLimit(
request: Request,
prefix: string,
perIpLimit: number,
windowMs: number,
): Promise<RateLimitResult & { ip: string | null }> {
const { ip, trustViolation } = getClientIpOrTrustWarning(request);
if (trustViolation) {
const result = await checkRateLimit(
TIGHT_ANON_KEY,
TIGHT_ANON_LIMIT,
TIGHT_ANON_WINDOW_MS,
);
return { ...result, ip };
}
const result = await checkRateLimit(
`${prefix}:${ip ?? "unknown"}`,
perIpLimit,
windowMs,
);
return { ...result, ip };
}