feat(security): in-process rate limit for sign-in and invite creation
Algorithm: a fixed-window token bucket implemented as a pure function in
`@tasks/shared` (`consumeTokenBucket`) plus a thin `apps/web` wrapper that
holds per-key state in a module-scoped `Map`. No Redis, no external deps —
horizontally-scaled deploys will need a Redis-backed swap behind the same
`rateLimit()` signature; called out in the JSDoc as a follow-up. The pure
core is unit-tested in `packages/shared` (6 new vitest cases covering
allow/deny, window reset, key isolation, monotonic retryAfterMs, denied-
flood pegging, and option validation); the wrapper is intentionally not
tested here because apps/web has no vitest harness yet.
Wire-ins (the two narrow surfaces called out in the v1 spec):
1. Credentials `authorize` in `apps/web/lib/auth.ts`: 5 attempts per
IP per 60s. IP comes from `next/headers` (x-forwarded-for first
entry, then x-real-ip); when headers() throws or returns nothing we
fall back to keying on "unknown" in prod and skipping the limiter
entirely in dev so a local test loop doesn't lock itself out. On a
trip we `console.warn` and return null — the standard Auth.js
"auth failed" signal — without consulting the DB.
2. `invites.create` in `apps/web/server/routers/invites.ts`: 10
invite-creates per inviter per hour. Keyed by inviter id (not
workspace) so a multi-workspace admin can't multiply their
allowance. On trip we throw TRPCError TOO_MANY_REQUESTS with a
retry-after seconds count baked into the message.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
29e69e964b
commit
58f92f3898
6 changed files with 402 additions and 0 deletions
|
|
@ -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<string | null> {
|
||||
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.");
|
||||
|
|
|
|||
100
apps/web/server/lib/rate-limit.ts
Normal file
100
apps/web/server/lib/rate-limit.ts
Normal file
|
|
@ -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<string, TokenBucketState>`. 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<RateLimitResult>` 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:<userId>`. 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<string, TokenBucketState>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -1 +1,7 @@
|
|||
export { generateId } from "./id";
|
||||
export {
|
||||
consumeTokenBucket,
|
||||
type TokenBucketOptions,
|
||||
type TokenBucketResult,
|
||||
type TokenBucketState,
|
||||
} from "./token-bucket";
|
||||
|
|
|
|||
109
packages/shared/src/utils/token-bucket.test.ts
Normal file
109
packages/shared/src/utils/token-bucket.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
110
packages/shared/src/utils/token-bucket.ts
Normal file
110
packages/shared/src/utils/token-bucket.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue