Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ HUB_STATIC_DIR=../web/dist
# SIGNUP_RATE_LIMIT_WINDOW_SECONDS=60
# SIGNUP_RATE_LIMIT_MAX=5

# Per-account rate limit on email sign-in, keyed on the target email
# rather than client IP (client IP can't be trusted as a sign-in key in
# this deployment — see sign-in-rate-limit.ts). Defaults to 10 attempts
# per 60 seconds when unset — deliberately looser than better-auth's
# built-in 3-per-10-seconds default, which is tight enough that a mistyped
# password can lock an account out mid-window. See CL-6494.
# SIGNIN_RATE_LIMIT_WINDOW_SECONDS=60
# SIGNIN_RATE_LIMIT_MAX=10

# Self-serve signup mode. Hub default is closed (owner adds users or
# shares a copy-link invite). Local `bun run dev` opens signup when this
# is unset so the first admin can seed — set closed explicitly to test
Expand Down
32 changes: 32 additions & 0 deletions apps/hub/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ const HubEnv = type({
"SIGNUP_RATE_LIMIT_MAX?": type(/^[1-9]\d*$/).describe(
"the maximum sign-ups a single IP may make per window, e.g. 5",
),
"SIGNIN_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe(
"the per-IP sign-in rate-limit window, in seconds, e.g. 60; overrides better-auth's built-in 10-second/3-attempt default, which is too tight for a person retyping a password",
),
"SIGNIN_RATE_LIMIT_MAX?": type(/^[1-9]\d*$/).describe(
"the maximum sign-in attempts a single IP may make per window, e.g. 10",
),
"WORKBENCH_SIGNUP?": type("'open' | 'closed'").describe(
"open = self-serve email signup allowed; closed (default) = owner adds users or copy-link invite only",
),
Expand Down Expand Up @@ -194,6 +200,20 @@ const HubEnv = type({

const DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS = 60;
const DEFAULT_SIGNUP_RATE_LIMIT_MAX = 5;
// better-auth's own built-in special rule for /sign-in* is 3 attempts per
// 10 seconds, keyed per client IP -- too tight for a bucket that can end up
// shared (CL-6494): when the IP can't be resolved, or is forged, every
// signed-out visitor, or an attacker replaying the same forged header, can
// starve it. index.ts now disables that built-in rule for sign-in entirely
// (see sign-in-rate-limit.ts) and uses these knobs to configure its
// account-keyed replacement instead, applied the same in every environment
// rather than branched on NODE_ENV (this file already rejects inferring
// auth behavior from NODE_ENV — see `rateLimit.enabled` below). This gives
// real users room to mistype a password, and a lone local developer room
// to keep working even while no IP can be resolved, without loosening
// brute-force resistance.
const DEFAULT_SIGNIN_RATE_LIMIT_WINDOW_SECONDS = 60;
const DEFAULT_SIGNIN_RATE_LIMIT_MAX = 10;

/**
* Production default for `WORKBENCH_CHAT_IDLE_REAP_MS`: 30 minutes,
Expand Down Expand Up @@ -293,6 +313,10 @@ export type HubConfig = {
readonly windowSeconds: number;
readonly max: number;
};
readonly signInRateLimit: {
readonly windowSeconds: number;
readonly max: number;
};
/** Self-serve signup. Default closed — see docs/TENANCY.md. */
readonly signupMode: "open" | "closed";
/** Domains allowed when signupMode is open. Empty = any domain. */
Expand Down Expand Up @@ -599,6 +623,14 @@ export function readHubConfig(
? Number(parsed.SIGNUP_RATE_LIMIT_MAX)
: DEFAULT_SIGNUP_RATE_LIMIT_MAX,
},
signInRateLimit: {
windowSeconds: parsed.SIGNIN_RATE_LIMIT_WINDOW_SECONDS
? Number(parsed.SIGNIN_RATE_LIMIT_WINDOW_SECONDS)
: DEFAULT_SIGNIN_RATE_LIMIT_WINDOW_SECONDS,
max: parsed.SIGNIN_RATE_LIMIT_MAX
? Number(parsed.SIGNIN_RATE_LIMIT_MAX)
: DEFAULT_SIGNIN_RATE_LIMIT_MAX,
},
envProviderKeys: envProviderKeysFrom(parsed),
envProviderBaseUrls: envProviderBaseUrlsFrom(parsed),
envCredentialPlantAdmin: {
Expand Down
66 changes: 66 additions & 0 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ import {

import { betterAuth } from "better-auth";
import { createBenchSessionMinter } from "./bench-session";
import { createSignInAttemptLimiter } from "./sign-in-rate-limit";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { type Context, Hono, type Next } from "hono";

Expand Down Expand Up @@ -372,6 +373,7 @@ const MAX_TARBALL_BYTES = 10 * 1024 * 1024;
// carry an unpublished scope anyway).
const TENANT_PREFIX = "/api/tenants/:tenantId";
const SIGN_UP_EMAIL_PATH = "/sign-up/email";
const SIGN_IN_EMAIL_PATH = "/sign-in/email";
// Chat residents carry a real hub-driven idle-reap again (reversing
// CL-5477's removal): the sidecar's own park/wake scheme it was meant to
// replace has itself been retired in favor of a simpler reap-and-relaunch
Expand Down Expand Up @@ -553,6 +555,25 @@ export async function createHub(config: HubConfig) {
database: drizzleAdapter(db, { provider: "pg" }),
emailAndPassword: { enabled: true },
socialProviders: config.socialProviders,
// Client-IP resolution for the sign-up rate limit below. Railway's docs
// (docs.railway.com/networking/public-networking/specs-and-limits) list
// `X-Real-IP` as the header its edge sets for the client's address —
// that's the only claim about it this codebase can actually stand
// behind. It is deliberately NOT relied on for sign-in: Railway's
// private networking lets any same-project service (sidecars included)
// reach this hub directly, bypassing the edge, and with no
// `trustedProxies` configured (Railway publishes no stable edge CIDR
// list to populate one with) a single-value header is trusted verbatim
// regardless of who set it. That's an acceptable, low-stakes gap for
// sign-up's coarse throttling — a closed-by-default, operator-gated
// path — but not for brute-force resistance on sign-in, which is why
// sign-in has its own account-keyed limiter instead (see
// `sign-in-rate-limit.ts`).
advanced: {
ipAddress: {
ipAddressHeaders: ["x-real-ip"],
},
},
rateLimit: {
// Explicit and always on: better-auth's own default only enables
// this in production (`enabled ?? isProduction`), which would
Expand All @@ -564,6 +585,13 @@ export async function createHub(config: HubConfig) {
window: config.signupRateLimit.windowSeconds,
max: config.signupRateLimit.max,
},
// `false` fully disables better-auth's own built-in special rule
// for /sign-in* (3 attempts / 10 seconds, keyed on the client IP
// above) rather than leaving it running in parallel as a second,
// weaker mechanism: that IP key is exactly what CL-6494's
// private-network bypass defeats, so enforcement for this path
// lives entirely in `signInAttemptLimiter` below instead.
[SIGN_IN_EMAIL_PATH]: false,
},
},
// No mailer is wired up anywhere in this stack, so better-auth can
Expand All @@ -586,6 +614,13 @@ export async function createHub(config: HubConfig) {
}
: undefined,
});
// Account-keyed sign-in rate limit (CL-6494) — see `sign-in-rate-limit.ts`
// for why this replaces better-auth's own IP-keyed sign-in enforcement
// entirely rather than composing with it.
const signInAttemptLimiter = createSignInAttemptLimiter(
config.signInRateLimit.windowSeconds,
config.signInRateLimit.max,
);
const { signingKey, agentRepoStore, assetService } =
await createBootAssetWiring({ db, dataDir: config.hubDataDir });
const baseLookups = createHubSessionLookups({ db, agentRepoStore });
Expand Down Expand Up @@ -977,6 +1012,37 @@ export async function createHub(config: HubConfig) {
}
}
}
// Account-keyed sign-in brute-force protection (CL-6494) — see
// `sign-in-rate-limit.ts` for why this fully replaces better-auth's
// own IP-keyed enforcement for this path instead of running beside
// it.
if (c.req.method === "POST" && c.req.path.endsWith(SIGN_IN_EMAIL_PATH)) {
let email = "";
try {
const body: unknown = await c.req.raw.clone().json();
if (
body !== null &&
typeof body === "object" &&
"email" in body &&
typeof (body as { email: unknown }).email === "string"
) {
email = (body as { email: string }).email;
}
} catch {
email = "";
}
const decision = signInAttemptLimiter.consume(email);
if (!decision.allowed) {
return c.json(
{
error: "rate_limited",
message: `Too many sign-in attempts. Try again in ${decision.retryAfterSeconds} second${decision.retryAfterSeconds === 1 ? "" : "s"}.`,
},
429,
{ "X-Retry-After": decision.retryAfterSeconds.toString() },
);
}
}
return auth.handler(c.req.raw);
},
db,
Expand Down
59 changes: 59 additions & 0 deletions apps/hub/src/sign-in-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import { createSignInAttemptLimiter } from "./sign-in-rate-limit.ts";

describe("createSignInAttemptLimiter", () => {
test("the Nth attempt against one account past the configured max is rejected", () => {
const limiter = createSignInAttemptLimiter(60, 2);

expect(limiter.consume("victim@example.com").allowed).toBe(true);
expect(limiter.consume("victim@example.com").allowed).toBe(true);
const throttled = limiter.consume("victim@example.com");

expect(throttled.allowed).toBe(false);
});

test("a rejected attempt reports how many seconds remain in the window", () => {
const limiter = createSignInAttemptLimiter(60, 1);

limiter.consume("victim@example.com");
const throttled = limiter.consume("victim@example.com");

expect(throttled.allowed).toBe(false);
if (!throttled.allowed) {
expect(throttled.retryAfterSeconds).toBeGreaterThan(0);
expect(throttled.retryAfterSeconds).toBeLessThanOrEqual(60);
}
});

test("rotating the caller-supplied identity per attempt does not grow the budget for the targeted account", () => {
// The whole point of keying on the account instead of client IP:
// a caller that varies some other, attacker-chosen value per request
// (a forged IP header, in production) still can't outrun the budget
// for the one email it's actually attacking, because the key is the
// email — nothing about a rotated header changes it.
const limiter = createSignInAttemptLimiter(60, 3);

expect(limiter.consume("victim@example.com").allowed).toBe(true);
expect(limiter.consume("victim@example.com").allowed).toBe(true);
expect(limiter.consume("victim@example.com").allowed).toBe(true);
expect(limiter.consume("victim@example.com").allowed).toBe(false);
expect(limiter.consume("victim@example.com").allowed).toBe(false);
});

test("two distinct accounts get independent budgets", () => {
const limiter = createSignInAttemptLimiter(60, 1);

expect(limiter.consume("alice@example.com").allowed).toBe(true);
expect(limiter.consume("alice@example.com").allowed).toBe(false);

// Bob's own budget is untouched by Alice's exhausted one.
expect(limiter.consume("bob@example.com").allowed).toBe(true);
});

test("email matching is case- and whitespace-insensitive, so it can't be sidestepped by casing/padding", () => {
const limiter = createSignInAttemptLimiter(60, 1);

expect(limiter.consume("Victim@Example.com").allowed).toBe(true);
expect(limiter.consume(" victim@example.com ").allowed).toBe(false);
});
});
108 changes: 108 additions & 0 deletions apps/hub/src/sign-in-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Account-keyed sign-in attempt limiter (CL-6494).
//
// better-auth's own rate limiter keys solely on client IP (falling back to
// one shared bucket when no IP resolves), configured via
// `advanced.ipAddress` + `rateLimit.customRules`. That key cannot be this
// deployment's sign-in defense: Railway's private networking lets any
// same-project service — including sidecars that run agent-driven shell
// commands — reach this hub directly at `<service>.railway.internal`,
// bypassing Railway's edge entirely. better-auth's `getIPFromHeader` trusts
// a single-value IP header verbatim whenever no `trustedProxies` is
// configured, and Railway's anycast edge publishes no stable CIDR list to
// populate one with. A caller on the private network can therefore send a
// fresh forged IP header on every request and get an independent
// rate-limit bucket each time — a complete bypass of brute-force
// protection, reachable from inside our own trust boundary.
//
// better-auth has no native extension point that fixes this: `customRules`
// can only override a matched path's `window`/`max` (or opt the path out
// entirely via `false`) — it never gets to change the key. `customStorage`
// only ever receives the already-computed `ip|path` key. Neither sees the
// request body, so neither can key on anything but that IP. This limiter
// is therefore deliberately separate from better-auth's engine —
// `index.ts` sets `customRules["/sign-in/email"]` to `false`, fully
// disabling better-auth's native, IP-keyed enforcement for this one path,
// rather than attempting to bend its extension points to a job they don't
// reach. Upstream note: better-auth would need a `customRules` (or
// pre-consume) hook that can see the parsed request body, or override the
// key itself, to express account-keyed limiting natively.
//
// Keyed on the normalized target email instead: that value isn't
// attacker-chosen the way a header is. An attacker rotating IP headers
// still cannot exceed the budget for the one account they're actually
// trying to break into, which is the threat brute-force limiting exists to
// stop. Client IP is deliberately not composed into the key: this
// deployment has no way to tell an edge-forwarded request from one that
// arrived over the private network with a forged header, so a
// header-derived IP is not a genuinely trustworthy signal here, and
// folding it into the key would only let the same forged-header trick
// defeat this limiter too, exactly as it defeats better-auth's. Once
// Railway traffic can be verifiably split (a stable edge CIDR list, or
// private networking segregated away from `/api/auth`), an IP-composed
// *secondary* per-source budget could be layered on top of this one, to
// also blunt one source spraying many different accounts.
//
// Guards against the account key itself becoming a way to lock a known
// user out of their own account: the window is short and the count is
// generous (60s / 10 by default — `config.signInRateLimit`), so a lockout
// an attacker forces self-heals within the window and never compounds
// across windows, while a real user mistyping a password a few times in a
// row is never affected.

const MAX_TRACKED_EMAILS = 50_000;

export type SignInAttemptDecision =
| { readonly allowed: true }
| { readonly allowed: false; readonly retryAfterSeconds: number };

export type SignInAttemptLimiter = {
consume(email: string): SignInAttemptDecision;
};

function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}

export function createSignInAttemptLimiter(
windowSeconds: number,
max: number,
): SignInAttemptLimiter {
const windowMs = windowSeconds * 1000;
const buckets = new Map<string, { count: number; windowStart: number }>();

function pruneExpiredAndOverflow(now: number): void {
for (const [key, bucket] of buckets) {
if (now - bucket.windowStart >= windowMs) buckets.delete(key);
}
if (buckets.size <= MAX_TRACKED_EMAILS) return;
let overflow = buckets.size - MAX_TRACKED_EMAILS;
for (const key of buckets.keys()) {
if (overflow <= 0) break;
buckets.delete(key);
overflow -= 1;
}
}

return {
consume(email: string): SignInAttemptDecision {
const now = Date.now();
pruneExpiredAndOverflow(now);
const key = normalizeEmail(email);
const bucket = buckets.get(key);
if (!bucket || now - bucket.windowStart >= windowMs) {
buckets.set(key, { count: 1, windowStart: now });
return { allowed: true };
}
if (bucket.count >= max) {
return {
allowed: false,
retryAfterSeconds: Math.ceil(
(bucket.windowStart + windowMs - now) / 1000,
),
};
}
bucket.count += 1;
return { allowed: true };
},
};
}
1 change: 1 addition & 0 deletions apps/hub/test/chat-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const config: HubConfig = {
hubDataDir: path.join(root, "data"),
hubStaticDir: staticDir,
signupRateLimit: { windowSeconds: 60, max: 5 },
signInRateLimit: { windowSeconds: 60, max: 10 },
socialProviders: {},
signupMode: "closed",
allowedEmailDomains: [],
Expand Down
Loading
Loading