mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
A refused address change returned before the update was built, so every other field in the same request went with it. The settings form posts the whole profile at once, so somebody who had spent the budget could not save a timezone or a height for an hour and was told it was about email addresses. The address is now dropped and the rest of the save lands, reported through the existing rejectedFields key with the code `rate_limited`; the 429 is kept for a request that asked for nothing else, where a partial success would be a lie. The comment claiming an audit row beside each question was inverted: both refusals returned before the row is written, so only the answers that told the caller nothing were being recorded. The two answers worth detecting write their own rows now. The stored address is normalised before the "has it changed" comparison. Registration writes it as typed, so an account created with capitals did not match its own address and paid for its first ordinary save.
323 lines
13 KiB
TypeScript
323 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() + ${windowInterval}::interval)
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
count = CASE
|
|
WHEN rate_limits.reset_at < NOW() THEN 1
|
|
ELSE rate_limits.count + 1
|
|
END,
|
|
reset_at = CASE
|
|
WHEN rate_limits.reset_at < NOW() THEN NOW() + ${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()
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Shared per-account ceiling for the single-record writes.
|
|
*
|
|
* 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 is the floor under the other direction:
|
|
* `POST /api/measurements`, `/api/mood-entries`, both medication-intake writes,
|
|
* `/api/labs`, a custom-metric entry, a cycle day-log, a vaccination, an
|
|
* allergy, a biomarker and an encounter.
|
|
*
|
|
* One shared bucket, not one per route, for the reason
|
|
* `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates
|
|
* 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. Ten of the eleven are wrapped in `withIdempotency`, so an honest
|
|
* retry storm is deduplicated before it is counted; `POST
|
|
* /api/medications/intake` is not, because it updates a named `intakeId`
|
|
* rather than creating a row, so a replay writes the status that is already
|
|
* there. 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 W13 M-4 — 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 };
|
|
}
|