diff --git a/apps/web/lib/auth.ts b/apps/web/lib/auth.ts index 821b0c3..e690661 100644 --- a/apps/web/lib/auth.ts +++ b/apps/web/lib/auth.ts @@ -5,6 +5,35 @@ import Credentials from "next-auth/providers/credentials"; import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; +import { rateLimit } from "@/server/lib/rate-limit"; + +const SIGNIN_RATE_LIMIT = { limit: 5, windowMs: 60_000 } as const; + +/** + * Best-effort IP extraction for the Credentials `authorize` callback. + * Auth.js v5 doesn't hand us the request, so we reach for the Next.js + * `headers()` helper — which works inside the route-handler context that + * `/api/auth/[...nextauth]` runs in, but may throw in other contexts + * (e.g. server actions during sign-in testing). On throw we return + * `null` and the caller decides whether to skip the limiter (dev) or + * lock the address as `"unknown"` (prod, so a header-stripping proxy + * doesn't accidentally disable the guardrail entirely). + */ +async function resolveSignInIp(): Promise { + try { + const { headers } = await import("next/headers"); + const h = await headers(); + const fwd = h.get("x-forwarded-for"); + if (fwd) { + const first = fwd.split(",")[0]?.trim(); + if (first) return first; + } + return h.get("x-real-ip") ?? null; + } catch { + return null; + } +} + /** * Session strategy is JWT (no `@auth/drizzle-adapter`). The callbacks below * are the manual replacement for what an adapter would normally do: @@ -317,6 +346,31 @@ const providers: NextAuthConfig["providers"] = [ const password = credentials?.password as string | undefined; if (!email?.trim() || !password) return null; + // Rate-limit by IP before doing any DB work. 5/minute is well above + // the rate a human can plausibly fat-finger a password but tight + // enough that an automated guesser hits the wall before it can + // burn through a meaningful dictionary slice. In dev we skip the + // limiter when we can't resolve an IP so a header-less local test + // run doesn't lock itself out. + const ip = await resolveSignInIp(); + const limiterKey = `signin:${ip ?? "unknown"}`; + const skipLimiter = ip === null && process.env.NODE_ENV === "development"; + if (!skipLimiter) { + const rl = rateLimit({ + key: limiterKey, + limit: SIGNIN_RATE_LIMIT.limit, + windowMs: SIGNIN_RATE_LIMIT.windowMs, + }); + if (!rl.allowed) { + console.warn( + "[auth] credentials sign-in rate-limited ip=%s retryAfterMs=%d", + ip ?? "unknown", + rl.retryAfterMs, + ); + return null; + } + } + const devPassword = process.env.AUTH_DEV_PASSWORD; if (!devPassword) { console.warn("[auth] AUTH_DEV_PASSWORD is not set; credentials sign-in disabled."); diff --git a/apps/web/server/lib/rate-limit.ts b/apps/web/server/lib/rate-limit.ts new file mode 100644 index 0000000..607adf1 --- /dev/null +++ b/apps/web/server/lib/rate-limit.ts @@ -0,0 +1,100 @@ +import { + consumeTokenBucket, + type TokenBucketState, +} from "@tasks/shared"; + +/** + * In-process, in-memory rate limiter built on top of the pure token bucket + * in `@tasks/shared`. Used as a first-line guardrail against brute-force + * (credentials sign-in) and casual abuse (invite spam) — see + * `plans/Plan-multitenant-saas-hardening/Epic-tenant-lifecycle/Task-rate-limit-and-abuse-guardrails.md`. + * + * Storage is a module-scoped `Map`. Entries are + * lazily expired on lookup (no background timer); a horizontally-scaled + * deployment would defeat the in-process map because every Next.js worker + * has its own copy. That's an explicit v1 trade-off — the limits in this + * file are sized so a single worker is enough to cap brute-force at the + * "annoy a human" threshold even if N workers each get N attempts. When + * the app moves to multi-region or multi-replica deploy, swap the body of + * `rateLimit` for a Redis-backed implementation behind the same exported + * type. Tracked as a follow-up in the task spec (the "Storage" section). + * + * NOT async on purpose: the in-memory implementation has no I/O and we + * want call sites to stay synchronous. The Redis follow-up will need its + * own async surface (`rateLimitAsync` or similar) rather than retrofitting + * `Promise` here, so existing callers don't have to be + * audited for `await` correctness when the storage backend changes. + */ + +export type RateLimitResult = + | { allowed: true; remaining: number; resetAt: Date } + | { + allowed: false; + remaining: 0; + resetAt: Date; + retryAfterMs: number; + }; + +export interface RateLimitOptions { + /** + * Globally unique bucket identifier. Convention: `${scope}:${actor}`, + * e.g. `signin:192.0.2.1`, `invite:create:`. The scope prefix + * is required — without it, two unrelated features sharing a key like + * a bare userId would collide. + */ + key: string; + /** Max allowed actions in `windowMs`. */ + limit: number; + /** Window length in milliseconds. */ + windowMs: number; +} + +// Eviction threshold: once the map grows past this, we opportunistically +// sweep expired entries on the *next* lookup. Picked to be large enough +// that we don't sweep on every cold start, small enough that a single +// rogue IP iterating keys can't OOM the process before we notice. +const EVICTION_THRESHOLD = 10_000; + +const buckets = new Map(); + +function maybeSweep(now: number): void { + if (buckets.size <= EVICTION_THRESHOLD) return; + for (const [k, v] of buckets) { + if (now >= v.resetAt) buckets.delete(k); + } +} + +export function rateLimit(opts: RateLimitOptions): RateLimitResult { + const now = Date.now(); + maybeSweep(now); + + const prior = buckets.get(opts.key); + const result = consumeTokenBucket(prior, now, { + limit: opts.limit, + windowMs: opts.windowMs, + }); + buckets.set(opts.key, result.state); + + if (result.allowed) { + return { + allowed: true, + remaining: result.remaining, + resetAt: new Date(result.resetAt), + }; + } + return { + allowed: false, + remaining: 0, + resetAt: new Date(result.resetAt), + retryAfterMs: result.retryAfterMs, + }; +} + +/** + * Test-only escape hatch so colocated unit tests (when we add a vitest + * harness for `apps/web`) can reset shared module state between cases. + * Not exported through any barrel — call sites should never need this. + */ +export function __resetRateLimitForTests(): void { + buckets.clear(); +} diff --git a/apps/web/server/routers/invites.ts b/apps/web/server/routers/invites.ts index 1a4070f..21d08e5 100644 --- a/apps/web/server/routers/invites.ts +++ b/apps/web/server/routers/invites.ts @@ -16,6 +16,7 @@ import { z } from "zod"; import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc"; import { userOwnsEmail } from "@/server/lib/identity"; +import { rateLimit } from "@/server/lib/rate-limit"; import { userEmailIdentities, workspaceInvites, @@ -113,6 +114,28 @@ export const invitesRouter = router({ const inviterId = ctx.session.user.id; + // Per-inviter rate limit: 10 invites per hour. Sized for normal + // onboarding bursts (a team of ~10 going through provisioning in + // one sitting) while still cutting off a script that's trying to + // spray invites across many addresses. Keyed by inviter, not by + // workspace, because a single bad actor with admin rights in + // multiple workspaces would otherwise multiply their allowance. + const inviteLimit = rateLimit({ + key: `invite:create:${inviterId}`, + limit: 10, + windowMs: 60 * 60 * 1_000, + }); + if (!inviteLimit.allowed) { + const retryAfterSec = Math.max( + 1, + Math.ceil(inviteLimit.retryAfterMs / 1_000), + ); + throw new TRPCError({ + code: "TOO_MANY_REQUESTS", + message: `You've hit the invite limit for this hour. Try again in about ${retryAfterSec} seconds.`, + }); + } + // Don't let inviters invite themselves — confusing failure mode. const [inviter] = await ctx.db .select({ email: users.email }) diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 96bbddb..5e1291f 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1 +1,7 @@ export { generateId } from "./id"; +export { + consumeTokenBucket, + type TokenBucketOptions, + type TokenBucketResult, + type TokenBucketState, +} from "./token-bucket"; diff --git a/packages/shared/src/utils/token-bucket.test.ts b/packages/shared/src/utils/token-bucket.test.ts new file mode 100644 index 0000000..fe004a2 --- /dev/null +++ b/packages/shared/src/utils/token-bucket.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { + consumeTokenBucket, + type TokenBucketState, +} from "./token-bucket"; + +const OPTS = { limit: 3, windowMs: 1_000 }; + +describe("consumeTokenBucket", () => { + it("allows the first `limit` requests and denies the next one", () => { + let state: TokenBucketState | undefined; + const now = 1_000; + + const r1 = consumeTokenBucket(state, now, OPTS); + expect(r1.allowed).toBe(true); + state = r1.state; + + const r2 = consumeTokenBucket(state, now, OPTS); + expect(r2.allowed).toBe(true); + state = r2.state; + + const r3 = consumeTokenBucket(state, now, OPTS); + expect(r3.allowed).toBe(true); + expect(r3.allowed && r3.remaining).toBe(0); + state = r3.state; + + const r4 = consumeTokenBucket(state, now, OPTS); + expect(r4.allowed).toBe(false); + expect(r4.remaining).toBe(0); + expect(!r4.allowed && r4.retryAfterMs).toBe(1_000); + }); + + it("starts a fresh window once the previous one elapses", () => { + let state: TokenBucketState | undefined; + for (let i = 0; i < OPTS.limit; i++) { + state = consumeTokenBucket(state, 1_000, OPTS).state; + } + const denied = consumeTokenBucket(state, 1_500, OPTS); + expect(denied.allowed).toBe(false); + + const afterReset = consumeTokenBucket(denied.state, 2_001, OPTS); + expect(afterReset.allowed).toBe(true); + expect(afterReset.allowed && afterReset.remaining).toBe(OPTS.limit - 1); + expect(afterReset.resetAt).toBe(2_001 + OPTS.windowMs); + }); + + it("treats distinct keys as independent buckets", () => { + // The function itself is keyless — the call site holds the map. This + // test asserts the *contract* that distinct prior states don't + // contaminate one another (the cheap stand-in for what the per-key + // map enforces in the wrapper). + let aState: TokenBucketState | undefined; + for (let i = 0; i < OPTS.limit; i++) { + aState = consumeTokenBucket(aState, 1_000, OPTS).state; + } + const aDenied = consumeTokenBucket(aState, 1_000, OPTS); + expect(aDenied.allowed).toBe(false); + + const bFirst = consumeTokenBucket(undefined, 1_000, OPTS); + expect(bFirst.allowed).toBe(true); + expect(bFirst.allowed && bFirst.remaining).toBe(OPTS.limit - 1); + }); + + it("returns monotonically decreasing retryAfterMs across denied calls in the same window", () => { + let state: TokenBucketState | undefined; + for (let i = 0; i < OPTS.limit; i++) { + state = consumeTokenBucket(state, 1_000, OPTS).state; + } + const denials = [1_100, 1_400, 1_900].map((now) => { + const r = consumeTokenBucket(state, now, OPTS); + state = r.state; + expect(r.allowed).toBe(false); + return !r.allowed ? r.retryAfterMs : Number.NaN; + }); + expect(denials[0]).toBeGreaterThan(denials[1]!); + expect(denials[1]).toBeGreaterThan(denials[2]!); + expect(denials[2]).toBeLessThanOrEqual(200); + }); + + it("keeps the counter pegged at `limit` across a flood of denied calls", () => { + // Guards against an implementation that increments unconditionally, + // which would let a denied flood inflate the counter and effectively + // extend the lockout past `windowMs` once the legitimate caller + // returns. + let state: TokenBucketState | undefined; + for (let i = 0; i < OPTS.limit; i++) { + state = consumeTokenBucket(state, 1_000, OPTS).state; + } + for (let i = 0; i < 50; i++) { + const r = consumeTokenBucket(state, 1_000, OPTS); + expect(r.allowed).toBe(false); + state = r.state; + } + expect(state!.count).toBe(OPTS.limit); + + const afterReset = consumeTokenBucket(state, 2_001, OPTS); + expect(afterReset.allowed).toBe(true); + }); + + it("rejects invalid options early", () => { + expect(() => + consumeTokenBucket(undefined, 0, { limit: 0, windowMs: 1_000 }), + ).toThrow(); + expect(() => + consumeTokenBucket(undefined, 0, { limit: 5, windowMs: 0 }), + ).toThrow(); + }); +}); diff --git a/packages/shared/src/utils/token-bucket.ts b/packages/shared/src/utils/token-bucket.ts new file mode 100644 index 0000000..ad45c3d --- /dev/null +++ b/packages/shared/src/utils/token-bucket.ts @@ -0,0 +1,110 @@ +/** + * Pure, allocation-cheap fixed-window counter used by the in-process rate + * limiter in `apps/web/server/lib/rate-limit.ts`. Lives in `@tasks/shared` + * so the algorithm can be unit-tested in isolation — `apps/web` has no + * vitest harness yet (see Task-bootstrap-vitest-for-apps-web). + * + * The shape is a fixed window, not a leaky bucket: a single counter that + * resets at `resetAt`. This is the simplest correct primitive for "N + * attempts per window per key" guardrails and matches the limits described + * in the rate-limit task spec (5/min for sign-in, 10/hr for invites). A + * sliding-log or leaky-bucket would be more accurate around window + * boundaries, but the cost is more state per key for no real abuse-control + * win at these limits. + * + * The function is pure: callers pass in the current state (or `undefined` + * to start fresh) plus the wall clock as `now`. The returned `state` field + * is the value the caller should store back into its key→state map. This + * keeps the bucket trivially testable with a fake clock and avoids any + * hidden global mutable state inside `@tasks/shared`. + */ + +export interface TokenBucketState { + /** Attempts consumed within the current window. */ + count: number; + /** Epoch milliseconds at which the current window expires. */ + resetAt: number; +} + +export interface TokenBucketOptions { + /** Max allowed attempts per window. Must be >= 1. */ + limit: number; + /** Window length in milliseconds. Must be >= 1. */ + windowMs: number; +} + +export type TokenBucketResult = + | { + allowed: true; + remaining: number; + resetAt: number; + state: TokenBucketState; + } + | { + allowed: false; + remaining: 0; + resetAt: number; + retryAfterMs: number; + state: TokenBucketState; + }; + +/** + * Charge one attempt against `state` for the bucket described by `opts`. + * + * - If `state` is undefined or its window has expired (`now >= resetAt`), + * a fresh window starts: count = 1, resetAt = now + windowMs. + * - Otherwise the count is incremented. If the post-increment count + * exceeds `limit`, the call is denied; the returned `state` does NOT + * include the over-limit increment, so a flood of denied calls cannot + * inflate the counter past `limit + 1`. + * + * Returning the next-state alongside the decision means the call site + * doesn't need to special-case "denied" vs "allowed" when writing back — + * `result.state` is always the value to persist. + */ +export function consumeTokenBucket( + state: TokenBucketState | undefined, + now: number, + opts: TokenBucketOptions, +): TokenBucketResult { + if (!Number.isFinite(opts.limit) || opts.limit < 1) { + throw new Error("token-bucket: limit must be >= 1"); + } + if (!Number.isFinite(opts.windowMs) || opts.windowMs < 1) { + throw new Error("token-bucket: windowMs must be >= 1"); + } + + const windowExpired = !state || now >= state.resetAt; + if (windowExpired) { + const next: TokenBucketState = { count: 1, resetAt: now + opts.windowMs }; + return { + allowed: true, + remaining: opts.limit - 1, + resetAt: next.resetAt, + state: next, + }; + } + + const nextCount = state.count + 1; + if (nextCount > opts.limit) { + // Keep count pegged at limit so repeated denied calls don't inflate + // the counter unboundedly. retryAfterMs shrinks as time advances + // within the same denied window. + const pegged: TokenBucketState = { count: opts.limit, resetAt: state.resetAt }; + return { + allowed: false, + remaining: 0, + resetAt: state.resetAt, + retryAfterMs: Math.max(0, state.resetAt - now), + state: pegged, + }; + } + + const next: TokenBucketState = { count: nextCount, resetAt: state.resetAt }; + return { + allowed: true, + remaining: opts.limit - nextCount, + resetAt: next.resetAt, + state: next, + }; +}