From cafd5130193d0edb031aa31a71611bbb7e434261 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sun, 26 Jul 2026 12:20:51 -0500 Subject: [PATCH] fix: secure authentication request identity Replace Better Auth's default trust of a caller-controlled X-Forwarded-For with a platform-owned client-address resolution boundary: direct mode (the safe default) trusts only the real socket peer, and an explicit trusted-proxy mode requires a header name, a concrete trusted-proxy list, and only honors a forwarded chain from an actual trusted peer. This closes a rate-limit bypass where rotating a spoofed header let a caller choose a fresh bucket on every request. Public sign-up is also disabled by default and requires an explicit typed opt-in, using Better Auth's own disableSignUp gate so no email-existence leak or second signup path is introduced. Co-Authored-By: Claude Sonnet 5 --- apps/api/src/app.ts | 13 +- apps/api/src/client-address.ts | 66 +++ apps/api/src/config.ts | 12 +- apps/api/src/server.ts | 60 ++- apps/api/tests/api-http.integration.ts | 409 +++++++++++++++++- apps/api/tests/app.test.ts | 19 + apps/api/tests/client-address.test.ts | 113 +++++ apps/api/tests/config.test.ts | 30 ++ apps/api/tests/module-database.integration.ts | 1 + core/src/cli/doctor.ts | 19 + core/src/diagnostics/doctor-report.ts | 2 +- core/src/diagnostics/doctor.ts | 53 ++- core/src/identity/client-address.ts | 280 ++++++++++++ core/src/identity/config.ts | 35 +- core/src/identity/index.ts | 1 + core/src/identity/runtime.ts | 35 +- core/tests/doctor-report.test.ts | 2 + core/tests/doctor.test.ts | 132 ++++++ core/tests/identity-client-address.test.ts | 350 +++++++++++++++ core/tests/identity-config.test.ts | 36 ++ core/tests/identity-runtime.test.ts | 38 +- docs/development/api.md | 104 ++++- 22 files changed, 1772 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/client-address.ts create mode 100644 apps/api/tests/client-address.test.ts create mode 100644 core/src/identity/client-address.ts create mode 100644 core/tests/identity-client-address.test.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4a10815..4fe5787 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -119,7 +119,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono { app.on(["POST", "GET"], "/api/auth/*", async (c) => { try { - return await runtime.handleAuthRequest(c.req.raw); + const response = await runtime.handleAuthRequest(c.req.raw); + // Every Better Auth response — including a session cookie — must never be cached by an + // intermediary; constructing a fresh Response (rather than relying on `c.header()`, which + // only affects `c.res`) guarantees this regardless of how the handler produced its result, + // and never reads or buffers the body. + const headers = new Headers(response.headers); + headers.set("Cache-Control", "no-store"); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); } catch (error) { return sanitizedErrorResponse(c, error, 500); } diff --git a/apps/api/src/client-address.ts b/apps/api/src/client-address.ts new file mode 100644 index 0000000..7ba8ed1 --- /dev/null +++ b/apps/api/src/client-address.ts @@ -0,0 +1,66 @@ +import { type ClientAddressResolver, TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity"; +import type { Http2Bindings, HttpBindings } from "@hono/node-server"; + +type NodeServerBindings = HttpBindings | Http2Bindings; + +export { + type ClientAddressConfig, + ClientAddressConfigError, + type ClientAddressConfigErrorCode, + type ClientAddressConfigInput, + type ClientAddressFailureReason, + type ClientAddressMode, + type ClientAddressResolution, + type ClientAddressResolver, + createClientAddressConfig, + createClientAddressResolver, + type DirectClientAddressConfig, + findInvalidTrustedProxies, + normalizeClientAddress, + type TrustedProxyClientAddressConfig, +} from "@ekklesia/core/identity"; + +/** A fixed, generic diagnostic — never the peer address, forwarded header content, request path, or + * any other request detail — logged only when client-address resolution fails closed. */ +export const CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE = + "[client-address] A request's caller address could not be resolved safely; rejecting with 503."; + +function sanitizedClientAddressUnresolvedResponse(): Response { + return new Response(JSON.stringify({ code: "CLIENT_ADDRESS_UNRESOLVED" }), { + headers: { "cache-control": "no-store", "content-type": "application/json" }, + status: 503, + }); +} + +/** + * Wraps a `@hono/node-server` fetch callback at the exact Node/API transport boundary — the one + * place in this repository below `apps/api` that reads the raw Node `IncomingMessage` — with + * Core's framework-agnostic client-address resolution policy (`@ekklesia/core/identity`). Resolves + * the caller's trusted address from the real socket peer (`env.incoming.socket.remoteAddress`) + * before the wrapped callback — and therefore Better Auth's own rate limiter — ever sees the + * request, then overwrites (never merely appends) any externally supplied copy of + * `TRUSTED_CLIENT_ADDRESS_HEADER` with that resolved value. A caller can therefore never choose its + * own resolved address, whether by spoofing a forwarded header or by sending the internal header + * directly. Resolution failure never reaches the wrapped callback at all — no Better Auth logic, no + * Core mutation, and no module handler ever runs for a request whose caller address could not be + * resolved safely. + */ +export function wrapFetchWithClientAddress( + fetchCallback: (request: Request, env: NodeServerBindings) => Response | Promise, + resolver: ClientAddressResolver, +): (request: Request, env: NodeServerBindings) => Promise { + return async (request: Request, env: NodeServerBindings): Promise => { + const resolution = resolver.resolve({ + headers: request.headers, + peerAddress: env.incoming.socket.remoteAddress, + }); + if (!resolution.ok) { + console.error(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE); + return sanitizedClientAddressUnresolvedResponse(); + } + const headers = new Headers(request.headers); + headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, resolution.address); + const sanitizedRequest = new Request(request, { headers }); + return fetchCallback(sanitizedRequest, env); + }; +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 1843153..d48fe1c 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -4,6 +4,11 @@ import { type IdentityConfig, type IdentityConfigInput, } from "@ekklesia/core/identity"; +import { + type ClientAddressConfig, + type ClientAddressConfigInput, + createClientAddressConfig, +} from "./client-address.js"; export type ApiConfigErrorCode = | "DATABASE_URL_INVALID" @@ -37,6 +42,7 @@ const defaultHost = "127.0.0.1"; const defaultPort = 3000; export interface ApiRuntimeConfig { + readonly clientAddress: ClientAddressConfig; readonly databaseUrl: string; readonly host: string; readonly identity: IdentityConfig; @@ -44,6 +50,9 @@ export interface ApiRuntimeConfig { } export interface ApiRuntimeConfigInput { + /** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy + * trust unless explicitly configured. See `./client-address.js`. */ + readonly clientAddress?: ClientAddressConfigInput; readonly databaseUrl?: unknown; readonly host?: unknown; readonly identity: IdentityConfigInput; @@ -112,6 +121,7 @@ export function createApiRuntimeConfig( input.identity, options.environment === undefined ? {} : { environment: options.environment }, ); + const clientAddress = createClientAddressConfig(input.clientAddress); - return Object.freeze({ databaseUrl, host, identity, port }); + return Object.freeze({ clientAddress, databaseUrl, host, identity, port }); } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 6981599..095cedb 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -14,6 +14,12 @@ import { import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; import { createApiApp } from "./app.js"; +import { + type ClientAddressConfigInput, + type ClientAddressResolver, + createClientAddressResolver, + wrapFetchWithClientAddress, +} from "./client-address.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js"; @@ -61,15 +67,29 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi } } -function bindServer(app: Hono, host: string, port: number): Promise { +/** + * Binds the Node HTTP server. `wrapFetchWithClientAddress` resolves and sanitizes the caller's + * address from the real socket peer before any route (including Better Auth's own rate limiter) + * ever sees the request — see `./client-address.js` for the platform-owned client-address + * resolution policy this depends on. + */ +function bindServer( + app: Hono, + host: string, + port: number, + clientAddressResolver: ClientAddressResolver, +): Promise { return new Promise((resolve, reject) => { let settled = false; - const server = serve({ fetch: app.fetch, hostname: host, port }, () => { - if (!settled) { - settled = true; - resolve(server); - } - }); + const server = serve( + { fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver), hostname: host, port }, + () => { + if (!settled) { + settled = true; + resolve(server); + } + }, + ); server.once("error", () => { if (!settled) { settled = true; @@ -178,9 +198,11 @@ export async function startApi( throw error; } + const clientAddressResolver = createClientAddressResolver(config.clientAddress); + let server: ServerType; try { - server = await bindServer(app, config.host, config.port); + server = await bindServer(app, config.host, config.port, clientAddressResolver); } catch (error) { await safeStopModuleRuntime(moduleRuntime); await safeCloseDatabase(databaseHandle); @@ -250,6 +272,25 @@ export function installShutdownSignalHandlers( process.on("SIGTERM", handleSignal); } +function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined { + const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE; + if (mode === undefined) { + // Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/ + // `trustedProxies` only when `mode` is actually set keeps an operator from accidentally + // half-configuring proxy mode by setting only one of the three variables. + return undefined; + } + const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES; + return { + header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER, + mode, + trustedProxies: trustedProxiesValue + ?.split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + }; +} + function readConfigFromEnvironment(): { readonly input: Parameters[0]; readonly environment: "development" | "production"; @@ -259,14 +300,17 @@ function readConfigFromEnvironment(): { ?.split(",") .map((origin) => origin.trim()) .filter((origin) => origin.length > 0); + const clientAddress = readClientAddressInputFromEnvironment(); return { environment: process.env.NODE_ENV === "development" ? "development" : "production", input: { + ...(clientAddress === undefined ? {} : { clientAddress }), databaseUrl: process.env.DATABASE_URL, host: process.env.EKKLESIA_API_HOST, identity: { baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL, + publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED, secret: process.env.EKKLESIA_IDENTITY_SECRET, trustedOrigins, }, diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index c25af5a..1d5e579 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -18,6 +18,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; +import type { ClientAddressConfigInput } from "../src/client-address.js"; import { createApiRuntimeConfig } from "../src/config.js"; import { startApi, type RunningApi } from "../src/server.js"; @@ -325,16 +326,30 @@ function apiOrigin(running: RunningApi): string { return `http://127.0.0.1:${address.port}`; } -async function startMigratedApi( +interface StartConfiguredApiOptions { + readonly clientAddress?: ClientAddressConfigInput; + readonly modulesDirectoryOverride?: string; + /** Defaults to `true`: almost every existing scenario in this suite signs accounts up over HTTP + * and needs it enabled. Pass `false` explicitly for the dedicated signup-gating scenarios. */ + readonly publicSignupEnabled?: boolean; + /** Defaults to a generous override so this suite's own request volume never trips Better Auth's + * strict built-in sign-in/sign-up rule. The dedicated rate-limit/client-address scenarios pass + * `undefined` explicitly to exercise the real, unmodified production rule instead. */ + readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined; +} + +async function startConfiguredApi( url: string, - modulesDirectoryOverride?: string, + options: StartConfiguredApiOptions = {}, ): Promise { const config = createApiRuntimeConfig( { + ...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }), databaseUrl: url, host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, + publicSignupEnabled: options.publicSignupEnabled ?? true, secret: "integration-test-secret-0123456789abcdefgh", trustedOrigins: [identityBaseUrl, dashboardOrigin], }, @@ -342,13 +357,26 @@ async function startMigratedApi( }, { environment: "production" }, ); + return startApi(config, { + ...("rateLimitOverride" in options + ? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) } + : { rateLimitOverride: { max: 1000, window: 1 } }), + ...(options.modulesDirectoryOverride === undefined + ? {} + : { modulesDirectoryOverride: options.modulesDirectoryOverride }), + }); +} + +async function startMigratedApi( + url: string, + modulesDirectoryOverride?: string, +): Promise { // Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets // either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise // reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point // permission composition at a real, disposable module fixture instead of this repository's own // (empty) `modules/` directory. - return startApi(config, { - rateLimitOverride: { max: 1000, window: 1 }, + return startConfiguredApi(url, { ...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }), }); } @@ -456,6 +484,69 @@ function rawPostRequest( }); } +interface RawSignInAttemptOptions { + /** Originates the outbound connection from this local address — real, distinct loopback + * addresses (127.0.0.2, 127.0.0.3, ...) are all independently routable on the loopback interface, + * so this proves genuinely different real socket peers, not merely different header claims. */ + readonly localAddress?: string; + /** A raw, unvalidated `X-Forwarded-For` header value to send — deliberately not parsed or + * validated here, since these tests are proving how the *server* handles it. */ + readonly forwardedFor?: string; +} + +interface RawResponse { + readonly body: string; + readonly headers: Record; + readonly status: number; +} + +/** + * A raw `node:http` sign-in attempt against a deliberately wrong password. Better Auth's rate + * limiter keys and counts every `/sign-in*` request before ever validating credentials, so this + * never needs a real account and always resolves to either `401` (accepted by the limiter, wrong + * credentials) or `429` (rejected by the limiter) — the only two outcomes these tests distinguish. + * Raw `node:http`, not `fetch()`, for the same Fetch-Metadata reason `rawPostRequest` documents. + */ +function rawSignInAttempt( + origin: string, + options: RawSignInAttemptOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const url = new URL("/api/auth/sign-in/email", origin); + const payload = JSON.stringify({ + email: "nobody-in-particular@example.test", + password: "definitely-the-wrong-password", + }); + const req = httpRequest( + url, + { + headers: { + "content-length": Buffer.byteLength(payload), + "content-type": "application/json", + ...(options.forwardedFor === undefined + ? {} + : { "x-forwarded-for": options.forwardedFor }), + }, + ...(options.localAddress === undefined ? {} : { localAddress: options.localAddress }), + method: "POST", + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(chunk as Buffer)); + res.on("end", () => { + resolve({ + body: Buffer.concat(chunks).toString("utf8"), + headers: res.headers, + status: res.statusCode ?? 0, + }); + }); + }, + ); + req.on("error", reject); + req.end(payload); + }); +} + describe.sequential("live API — signup, sign-in, principal, sign-out", () => { let database: { name: string; url: string }; let running: RunningApi; @@ -798,6 +889,7 @@ describe.sequential("live API — startup and migration guarantees", () => { host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, + publicSignupEnabled: true, secret: "integration-test-secret-0123456789abcdefgh", trustedOrigins: [identityBaseUrl], }, @@ -1528,3 +1620,312 @@ describe.sequential("live API — installed module permission end-to-end", () => await dropDatabase(database.name); }); }); + +describe.sequential("live API — public signup is disabled by default", () => { + it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => { + const database = await createDisposableDatabase("signup_disabled"); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url, { publicSignupEnabled: false }); + try { + const origin = apiOrigin(running); + const response = await fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ + email: "blocked@example.test", + name: "Blocked", + password: "correct horse battery staple", + }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + const text = await response.text(); + + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(text.toLowerCase()).not.toContain("stack"); + expect(text).not.toContain(database.url); + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ count: string }>( + admin, + 'select count(*)::text as count from "ekklesia_core_user"', + ); + expect(rows[0]?.count).toBe("0"); + } finally { + await safeEnd(admin); + } + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("gives an identical rejection whether or not the requested email is already registered", async () => { + const database = await createDisposableDatabase("signup_disabled_enum"); + await applyCoreMigrations(database.url); + const existingEmail = "already-registered@example.test"; + + // Briefly enable signup on this same database to seed one real, pre-existing account, matching + // how a production database might have been seeded before signup was later disabled. + const seedRunning = await startConfiguredApi(database.url, { publicSignupEnabled: true }); + try { + const seedSignUp = await signUp( + apiOrigin(seedRunning), + existingEmail, + "correct horse battery", + ); + expect(seedSignUp.response.status).toBe(200); + } finally { + await seedRunning.shutdown().catch(() => undefined); + } + + const running = await startConfiguredApi(database.url, { publicSignupEnabled: false }); + try { + const origin = apiOrigin(running); + const signUpAttempt = (email: string) => + fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ email, name: "X", password: "correct horse battery staple" }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + + const existingResponse = await signUpAttempt(existingEmail); + const newResponse = await signUpAttempt("brand-new-address@example.test"); + + expect(existingResponse.status).toBe(400); + expect(newResponse.status).toBe(400); + expect(existingResponse.status).toBe(newResponse.status); + expect(await existingResponse.text()).toBe(await newResponse.text()); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("keeps sign-in and existing-session behavior fully functional for a pre-existing account while signup stays disabled", async () => { + const database = await createDisposableDatabase("signup_disabled_signin"); + await applyCoreMigrations(database.url); + const email = "still-can-sign-in@example.test"; + const password = "correct horse battery staple"; + + const seedRunning = await startConfiguredApi(database.url, { publicSignupEnabled: true }); + try { + const seedSignUp = await signUp(apiOrigin(seedRunning), email, password); + expect(seedSignUp.response.status).toBe(200); + } finally { + await seedRunning.shutdown().catch(() => undefined); + } + + const running = await startConfiguredApi(database.url, { publicSignupEnabled: false }); + try { + const origin = apiOrigin(running); + const signInResult = await signIn(origin, email, password); + expect(signInResult.response.status).toBe(200); + expect(signInResult.cookiePair).toBeDefined(); + + const { body } = await fetchPrincipal(origin, { cookie: signInResult.cookiePair as string }); + expect(body).toMatchObject({ kind: "authenticated" }); + + const blockedSignUp = await fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ email: "another-new-address@example.test", name: "X", password }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + expect(blockedSignUp.status).toBe(400); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); +}); + +describe.sequential("live API — client-address resolution and auth rate-limit identity", () => { + let database: { name: string; url: string }; + let directModeApi: RunningApi; + let directOrigin: string; + + it("starts a direct-mode API (the safe default) against a freshly migrated database, with Better Auth's real unmodified rate-limit rule", async () => { + database = await createDisposableDatabase("client_address"); + await applyCoreMigrations(database.url); + directModeApi = await startConfiguredApi(database.url, { rateLimitOverride: undefined }); + directOrigin = apiOrigin(directModeApi); + expect(directModeApi.server.listening).toBe(true); + }); + + it("reproduces the original vulnerability's exact shape and proves it is closed: rotating spoofed X-Forwarded-For values never bypass the limiter in direct mode", async () => { + // Before this pass, Better Auth trusted a caller-controlled X-Forwarded-For directly: each + // distinct spoofed value looked like an unrelated client, so a caller could send an unlimited + // number of sign-in attempts merely by rotating the header on every request. Direct mode now + // resolves identity from the real socket peer only — the header is never consulted. + const peer = "127.0.0.21"; + const first = await rawSignInAttempt(directOrigin, { + forwardedFor: "1.1.1.1", + localAddress: peer, + }); + const second = await rawSignInAttempt(directOrigin, { + forwardedFor: "2.2.2.2", + localAddress: peer, + }); + const third = await rawSignInAttempt(directOrigin, { + forwardedFor: "3.3.3.3", + localAddress: peer, + }); + expect([first.status, second.status, third.status]).toEqual([401, 401, 401]); + + // The fabricated value on this fourth request is new *again* — under the pre-fix behavior this + // would still look like a brand-new, never-before-seen client and would succeed. It must not. + const fourth = await rawSignInAttempt(directOrigin, { + forwardedFor: "4.4.4.4", + localAddress: peer, + }); + expect(fourth.status).toBe(429); + // The limiter's own existing response semantics are preserved unmodified. + expect(fourth.headers["x-retry-after"]).toBeDefined(); + expect(JSON.parse(fourth.body)).toMatchObject({ message: expect.any(String) }); + + // A request from the identical real peer with *no* forwarded header at all is equally blocked + // — proving the header was never what determined the bucket, in either direction. + const fifth = await rawSignInAttempt(directOrigin, { localAddress: peer }); + expect(fifth.status).toBe(429); + }); + + it("gives distinct real peer addresses independent rate-limit buckets", async () => { + const peerA = "127.0.0.22"; + const peerB = "127.0.0.23"; + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401); + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401); + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401); + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(429); + + // An unrelated real peer's budget is completely untouched by peerA's exhaustion. + expect((await rawSignInAttempt(directOrigin, { localAddress: peerB })).status).toBe(401); + }); + + it("shuts down the direct-mode API", async () => { + await directModeApi.shutdown(); + }); + + let proxyModeApi: RunningApi; + let proxyOrigin: string; + const trustedProxyPeer = "127.0.0.24"; + + it("starts a second API in explicit trusted-proxy mode against the same migrated database", async () => { + proxyModeApi = await startConfiguredApi(database.url, { + clientAddress: { + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: [trustedProxyPeer], + }, + rateLimitOverride: undefined, + }); + proxyOrigin = apiOrigin(proxyModeApi); + }); + + it("honors a forwarded address only when it comes from the explicitly trusted proxy peer", async () => { + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(429); + + // A *different* forwarded client from the same trusted proxy gets its own independent bucket. + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.51", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + }); + + it("ignores a forwarded address claimed by an untrusted peer, using that peer's own real address instead", async () => { + // 203.0.113.50 is already exhausted above when legitimately forwarded by the trusted proxy — + // an untrusted peer merely claiming to *be* that address must not inherit its exhausted bucket, + // and must not be able to choose an arbitrary bucket of its own by forging the header either. + const untrustedPeer = "127.0.0.25"; + const response = await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: untrustedPeer, + }); + expect(response.status).toBe(401); + }); + + it("walks a chained forwarded header right-to-left, skipping the trusted-proxy hop to find the genuine client", async () => { + const chained = `203.0.113.60, ${trustedProxyPeer}`; + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(429); + }); + + it("fails safely with a sanitized 503 on a malformed forwarded chain from a trusted proxy, never falling back to a shared bucket", async () => { + const response = await rawSignInAttempt(proxyOrigin, { + forwardedFor: "not-an-ip", + localAddress: trustedProxyPeer, + }); + expect(response.status).toBe(503); + expect(JSON.parse(response.body)).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" }); + expect(response.headers["cache-control"]).toBe("no-store"); + }); + + it("shuts down the trusted-proxy-mode API and drops the shared database", async () => { + await proxyModeApi.shutdown(); + await dropDatabase(database.name); + }); +}); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 8daa650..253d4c4 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -207,6 +207,25 @@ describe("Ekklesia API Hono application", () => { expect(JSON.stringify(body)).not.toContain("postgresql://"); }); + it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => { + const runtime = fakeRuntime({ + handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })), + }); + const app = createApiApp({ + moduleRoutes: Object.freeze([]), + moduleServices: fakeModuleServices(), + authorization: fakeAuthorization({}), + runtime, + trustedOrigins: [trustedOrigin], + }); + + const response = await app.request("/api/auth/sign-in/email", { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.text()).toBe("auth-ok"); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + it("has deterministic 404 behavior for an unknown route", async () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), diff --git a/apps/api/tests/client-address.test.ts b/apps/api/tests/client-address.test.ts new file mode 100644 index 0000000..d6674aa --- /dev/null +++ b/apps/api/tests/client-address.test.ts @@ -0,0 +1,113 @@ +import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity"; +import { describe, expect, it, vi } from "vitest"; +import { + CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE, + createClientAddressResolver, + wrapFetchWithClientAddress, +} from "../src/client-address.js"; + +function fakeEnv(remoteAddress: string | undefined) { + return { + incoming: { socket: { remoteAddress } }, + outgoing: {}, + } as unknown as Parameters[0] extends ( + request: Request, + env: infer Env, + ) => unknown + ? Env + : never; +} + +describe("wrapFetchWithClientAddress", () => { + it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok")); + const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver); + + const request = new Request("https://example.test/api/auth/sign-in/email"); + await wrapped(request, fakeEnv("203.0.113.9")); + + expect(wrappedFetch).toHaveBeenCalledTimes(1); + const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request]; + expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9"); + }); + + it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok")); + const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver); + + const request = new Request("https://example.test/api/auth/sign-in/email", { + headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" }, + }); + await wrapped(request, fakeEnv("192.0.2.1")); + + const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request]; + expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1"); + // A single value, never a comma-joined "caller-value, resolved-value" — set(), not append(). + expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(","); + }); + + it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached")); + const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + const response = await wrapped( + new Request("https://example.test/api/auth/sign-in/email"), + fakeEnv(undefined), + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(wrappedFetch).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE); + } finally { + errorSpy.mockRestore(); + } + }); + + it("never logs the peer address, headers, or request path on a resolution failure", async () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + await wrapped( + new Request("https://example.test/api/auth/sign-in/email", { + headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" }, + }), + fakeEnv("not-a-valid-ip"), + ); + + const emitted = errorSpy.mock.calls.flat().map(String).join("\n"); + expect(emitted).not.toContain("super-secret-session"); + expect(emitted).not.toContain("203.0.113.1"); + expect(emitted).not.toContain("sign-in"); + expect(emitted).not.toContain("not-a-valid-ip"); + } finally { + errorSpy.mockRestore(); + } + }); + + it("preserves the request method, body, and other headers unrelated to the internal address header", async () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok")); + const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver); + + const request = new Request("https://example.test/api/auth/sign-up/email", { + body: JSON.stringify({ email: "a@example.test" }), + headers: { "content-type": "application/json", origin: "https://dashboard.example.test" }, + method: "POST", + }); + await wrapped(request, fakeEnv("192.0.2.1")); + + const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request]; + expect(forwardedRequest.method).toBe("POST"); + expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test"); + expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "a@example.test" })); + }); +}); diff --git a/apps/api/tests/config.test.ts b/apps/api/tests/config.test.ts index 91f8a08..00c2e95 100644 --- a/apps/api/tests/config.test.ts +++ b/apps/api/tests/config.test.ts @@ -21,10 +21,12 @@ describe("API runtime configuration", () => { const config = createApiRuntimeConfig(validInput(), { environment: "production" }); expect(config).toEqual({ + clientAddress: { mode: "direct" }, databaseUrl: validInput().databaseUrl, host: "127.0.0.1", identity: { baseUrl: "https://identity.example.test", + publicSignupEnabled: false, secret: validSecret, trustedOrigins: ["https://identity.example.test"], }, @@ -131,6 +133,34 @@ describe("API runtime configuration", () => { expect(config.identity.baseUrl).toBe("http://localhost:3000"); }); + it("defaults public signup to disabled and threads an explicit enablement through to identity config", () => { + expect(createApiRuntimeConfig(validInput()).identity.publicSignupEnabled).toBe(false); + expect( + createApiRuntimeConfig({ + ...validInput(), + identity: { ...validInput().identity, publicSignupEnabled: true }, + }).identity.publicSignupEnabled, + ).toBe(true); + }); + + it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => { + expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" }); + + const config = createApiRuntimeConfig({ + ...validInput(), + clientAddress: { + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: ["10.0.0.1"], + }, + }); + expect(config.clientAddress).toEqual({ + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: ["10.0.0.1"], + }); + }); + it("never leaks the database URL, secret, or trusted origins through a thrown error", () => { const hostileUrl = "postgresql://admin:s3cr3t@db.internal.example:5432/ekklesia"; let captured: unknown; diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 82112a7..47ccea0 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -263,6 +263,7 @@ async function startMigratedApi( host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, + publicSignupEnabled: true, secret: "integration-test-secret-0123456789abcdefgh", trustedOrigins: [identityBaseUrl, dashboardOrigin], }, diff --git a/core/src/cli/doctor.ts b/core/src/cli/doctor.ts index 570703a..f321caf 100644 --- a/core/src/cli/doctor.ts +++ b/core/src/cli/doctor.ts @@ -39,6 +39,7 @@ function requiredString(value: unknown): string { function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] { const secret = process.env.EKKLESIA_IDENTITY_SECRET; const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL; + const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED; const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; const trustedOrigins = trustedOriginsValue ? trustedOriginsValue @@ -50,11 +51,28 @@ function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConf return { ...(baseUrl ? { baseUrl } : {}), environment: process.env.NODE_ENV === "development" ? "development" : "production", + ...(publicSignupEnabled ? { publicSignupEnabled } : {}), ...(secret ? { secret } : {}), ...(trustedOrigins ? { trustedOrigins } : {}), }; } +function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] { + const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE; + if (mode === undefined) { + return undefined; + } + const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES; + return { + header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER, + mode, + trustedProxies: trustedProxiesValue + ?.split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + }; +} + export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise { const [rootValue, moduleSdkValue] = await Promise.all([ readJson(path.join(repositoryRoot, "package.json")), @@ -64,6 +82,7 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise< const moduleSdkPackage = rootPackageDocument(moduleSdkValue); const report = await diagnoseEkklesia({ + clientAddress: readClientAddressFromEnvironment(), databaseUrl: process.env.DATABASE_URL, identityConfiguration: readIdentityConfigurationFromEnvironment(), moduleDiscovery: { diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index bd4003c..eb0847f 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -14,7 +14,7 @@ const sections: readonly DoctorSection[] = Object.freeze([ section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), - section("Identity", ["identity.configuration"]), + section("Identity", ["identity.configuration", "identity.client-address"]), section("Object storage", ["object-storage.integration"]), ]); diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 88934a5..31b9cf3 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -7,11 +7,17 @@ import { } from "@ekklesia/contracts"; import { getTableName } from "drizzle-orm"; import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; +import { + type ClientAddressConfigInput, + ClientAddressConfigError, + createClientAddressConfig, +} from "../identity/client-address.js"; import { type CreateIdentityConfigOptions, createIdentityConfig, IdentityConfigError, } from "../identity/config.js"; +import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js"; import { identityAdapterSchema } from "../identity/schema.js"; import { createPostgresMigrationDatabase, @@ -44,6 +50,7 @@ export type DoctorCheckId = | "postgresql.connection" | "postgresql.plan" | "identity.configuration" + | "identity.client-address" | "object-storage.integration"; export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning"; @@ -64,11 +71,13 @@ export interface DoctorReport { export interface DoctorIdentityConfigurationOptions { readonly baseUrl?: string | undefined; readonly environment?: CreateIdentityConfigOptions["environment"]; + readonly publicSignupEnabled?: string | undefined; readonly secret?: string | undefined; readonly trustedOrigins?: readonly string[] | undefined; } export interface DoctorOptions { + readonly clientAddress?: ClientAddressConfigInput | undefined; readonly databaseUrl?: string | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly moduleDiscovery: ModuleDiscoveryOptions; @@ -363,6 +372,7 @@ function identityConfigurationCheck( const hasAnyValue = input?.secret !== undefined || input?.baseUrl !== undefined || + input?.publicSignupEnabled !== undefined || (input?.trustedOrigins?.length ?? 0) > 0; if (!hasAnyValue) { @@ -373,9 +383,10 @@ function identityConfigurationCheck( } try { - createIdentityConfig( + const config = createIdentityConfig( { baseUrl: input?.baseUrl, + publicSignupEnabled: input?.publicSignupEnabled, secret: input?.secret, trustedOrigins: input?.trustedOrigins, }, @@ -383,6 +394,9 @@ function identityConfigurationCheck( ); return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [ schemaDetail, + config.publicSignupEnabled + ? "Public signup is explicitly ENABLED." + : "Public signup is disabled (safe default).", ]); } catch (error) { const code = @@ -394,6 +408,41 @@ function identityConfigurationCheck( } } +/** + * Pure configuration validation only, mirroring `identityConfigurationCheck`: never resolves a real + * peer address or reads a real request. Omitted configuration is a `pass`, not a skip — direct mode + * (trusting only the actual immediate socket peer) is itself a complete, safe default, unlike + * identity configuration, which has no meaningful default at all. An explicitly configured + * trusted-proxy mode reports its header name and trusted-proxy count; an invalid or contradictory + * combination is a `failure`, distinguishing the three states this pass's audit specifically calls + * for: safe direct mode, explicitly configured proxy mode, and an invalid configuration. + */ +function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck { + try { + const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER); + if (config.mode === "direct") { + return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [ + "Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.", + ]); + } + return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [ + `Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`, + "The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.", + ]); + } catch (error) { + const code = + error instanceof ClientAddressConfigError + ? error.code + : "CLIENT_ADDRESS_CONFIGURATION_INVALID"; + return freezeCheck( + "identity.client-address", + "failure", + "Client-address configuration invalid", + [`[${code}] Configured client-address values failed validation.`], + ); + } +} + function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] { return [ freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]), @@ -548,6 +597,7 @@ export async function diagnoseEkklesia( } const identityCheck = identityConfigurationCheck(options.identityConfiguration); + const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress); const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [ "Core storage integration has not been implemented yet.", @@ -563,6 +613,7 @@ export async function diagnoseEkklesia( postgresConnectionCheck, postgresPlanCheck, identityCheck, + clientAddressDoctorCheck, storageCheck, ]); } diff --git a/core/src/identity/client-address.ts b/core/src/identity/client-address.ts new file mode 100644 index 0000000..b6649ce --- /dev/null +++ b/core/src/identity/client-address.ts @@ -0,0 +1,280 @@ +import net from "node:net"; + +export type ClientAddressMode = "direct" | "trusted-proxy"; + +export type ClientAddressConfigErrorCode = + | "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION" + | "CLIENT_ADDRESS_HEADER_INVALID" + | "CLIENT_ADDRESS_HEADER_MISSING" + | "CLIENT_ADDRESS_MODE_INVALID" + | "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY" + | "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"; + +const clientAddressConfigErrorMessages: Readonly> = + Object.freeze({ + CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION: + "Direct client-address mode must not also configure a header or trusted proxies.", + CLIENT_ADDRESS_HEADER_INVALID: "The client-address header must be a valid HTTP header name.", + CLIENT_ADDRESS_HEADER_MISSING: + "Trusted-proxy client-address mode requires an explicit header name.", + CLIENT_ADDRESS_MODE_INVALID: 'The client-address mode must be "direct" or "trusted-proxy".', + CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY: + "Trusted-proxy client-address mode requires at least one trusted proxy address or range.", + CLIENT_ADDRESS_TRUSTED_PROXY_INVALID: + "Every trusted proxy entry must be a valid IPv4/IPv6 address or CIDR range.", + }); + +/** Never carries the offending mode, header name, or trusted-proxy value. */ +export class ClientAddressConfigError extends Error { + readonly code: ClientAddressConfigErrorCode; + + constructor(code: ClientAddressConfigErrorCode) { + super(clientAddressConfigErrorMessages[code]); + this.name = "ClientAddressConfigError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +export interface DirectClientAddressConfig { + readonly mode: "direct"; +} + +/** + * `header` is the one header name the reverse proxy is trusted to set — the platform transport + * boundary (`apps/api/src/client-address.ts`) overwrites any externally supplied copy of it before + * this policy ever sees the request, so it never needs to be `X-Forwarded-For` specifically, though + * that remains the common choice. `trustedProxies` lists the proxy's own address(es)/range(s), never + * a broad private range that also covers real clients. + */ +export interface TrustedProxyClientAddressConfig { + readonly mode: "trusted-proxy"; + readonly header: string; + readonly trustedProxies: readonly string[]; +} + +export type ClientAddressConfig = DirectClientAddressConfig | TrustedProxyClientAddressConfig; + +export interface ClientAddressConfigInput { + readonly mode?: unknown; + readonly header?: unknown; + readonly trustedProxies?: unknown; +} + +const HEADER_TOKEN_PATTERN = /^[a-z0-9-]+$/u; +const CIDR_PREFIX_PATTERN = /^\d+$/u; +const IPV4_MAPPED_PATTERN = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/iu; + +/** + * Normalizes a single address token for consistent identity/comparison: plain IPv4 is returned + * as-is, an IPv4-mapped IPv6 address (`::ffff:a.b.c.d` — the form Node's own `socket.remoteAddress` + * and real reverse proxies actually emit for an IPv4 peer on a dual-stack listener) collapses to its + * embedded IPv4 form, and any other valid IPv6 address is lowercased. A zone id (`%eth0`) is + * rejected as ambiguous outside its own interface. Anything that is not a syntactically valid IPv4 + * or IPv6 address returns `undefined` rather than being guessed at. + */ +export function normalizeClientAddress(address: string): string | undefined { + const trimmed = address.trim(); + if (trimmed.length === 0) { + return undefined; + } + if (net.isIPv4(trimmed)) { + return trimmed; + } + if (trimmed.includes("%")) { + return undefined; + } + if (net.isIPv6(trimmed)) { + const mapped = IPV4_MAPPED_PATTERN.exec(trimmed); + if (mapped) { + const embedded = mapped[1] as string; + return net.isIPv4(embedded) ? embedded : undefined; + } + return trimmed.toLowerCase(); + } + return undefined; +} + +interface ParsedTrustedProxyEntry { + readonly network: string; + readonly prefix: number; + readonly family: "ipv4" | "ipv6"; +} + +function parseTrustedProxyEntry(entry: string): ParsedTrustedProxyEntry | undefined { + const slashIndex = entry.indexOf("/"); + const addressPart = slashIndex === -1 ? entry : entry.slice(0, slashIndex); + const network = normalizeClientAddress(addressPart); + if (!network) { + return undefined; + } + const family: "ipv4" | "ipv6" = net.isIPv4(network) ? "ipv4" : "ipv6"; + const maxPrefix = family === "ipv4" ? 32 : 128; + if (slashIndex === -1) { + return { family, network, prefix: maxPrefix }; + } + const prefixPart = entry.slice(slashIndex + 1); + if (!CIDR_PREFIX_PATTERN.test(prefixPart)) { + return undefined; + } + const prefix = Number(prefixPart); + return prefix <= maxPrefix ? { family, network, prefix } : undefined; +} + +/** Trusted-proxy entries that are not a valid IPv4/IPv6 address or CIDR range — used both by config + * validation and, if ever needed diagnostically, by Doctor. */ +export function findInvalidTrustedProxies(entries: readonly string[]): readonly string[] { + return entries.filter((entry) => parseTrustedProxyEntry(entry) === undefined); +} + +/** + * Validates explicit, typed client-address configuration. The safe default (`mode: "direct"`) is + * used whenever `mode` is entirely omitted; every other field is validated strictly, and an + * incomplete or contradictory combination (proxy details without proxy mode, proxy mode without + * proxy details) is rejected outright rather than guessed at. `internalHeaderName` is the one + * platform-owned header (`TRUSTED_CLIENT_ADDRESS_HEADER`, `./runtime.js`) a caller must never be + * able to configure as its own "trusted" source header. + */ +export function createClientAddressConfig( + input: ClientAddressConfigInput = {}, + internalHeaderName?: string, +): ClientAddressConfig { + const mode = input.mode ?? "direct"; + if (mode !== "direct" && mode !== "trusted-proxy") { + throw new ClientAddressConfigError("CLIENT_ADDRESS_MODE_INVALID"); + } + + if (mode === "direct") { + if (input.header !== undefined || input.trustedProxies !== undefined) { + throw new ClientAddressConfigError("CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION"); + } + return Object.freeze({ mode: "direct" }); + } + + if (typeof input.header !== "string" || input.header.trim().length === 0) { + throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_MISSING"); + } + const header = input.header.trim().toLowerCase(); + if (!HEADER_TOKEN_PATTERN.test(header)) { + throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID"); + } + if (internalHeaderName !== undefined && header === internalHeaderName) { + // The internal sanitized header is never a valid *source* header — trusting it as the + // caller-forwarded value would let a caller set its own "resolved" address directly. + throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID"); + } + + if (!Array.isArray(input.trustedProxies) || input.trustedProxies.length === 0) { + throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY"); + } + const trustedProxies = input.trustedProxies.map((entry) => { + if (typeof entry !== "string" || entry.trim().length === 0) { + throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"); + } + return entry.trim(); + }); + if (findInvalidTrustedProxies(trustedProxies).length > 0) { + throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"); + } + + return Object.freeze({ + header, + mode: "trusted-proxy", + trustedProxies: Object.freeze(trustedProxies), + }); +} + +export type ClientAddressFailureReason = + | "FORWARDED_CHAIN_EXHAUSTED" + | "FORWARDED_HEADER_MALFORMED" + | "FORWARDED_HEADER_MISSING" + | "PEER_ADDRESS_UNAVAILABLE"; + +export type ClientAddressResolution = + | { readonly ok: true; readonly address: string } + | { readonly ok: false; readonly reason: ClientAddressFailureReason }; + +export interface ClientAddressResolver { + resolve(input: { + readonly peerAddress: string | undefined; + readonly headers: Headers; + }): ClientAddressResolution; +} + +function buildTrustedProxyMatcher(trustedProxies: readonly string[]): (address: string) => boolean { + const blockList = new net.BlockList(); + for (const entry of trustedProxies) { + const parsed = parseTrustedProxyEntry(entry); + if (!parsed) { + // Config validation already rejects this at startup; a matcher is only ever built from an + // already-validated config, so this can only mean the config was constructed unsafely. + throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"); + } + blockList.addSubnet(parsed.network, parsed.prefix, parsed.family); + } + return (address: string): boolean => + blockList.check(address, net.isIPv4(address) ? "ipv4" : "ipv6"); +} + +/** + * Builds the one server-owned client-address resolution policy this API runtime uses for every + * request, from already-validated configuration. Built once at startup (never per-request) so the + * trusted-proxy `BlockList` is compiled a single time. Depends only on `node:net` and the standard + * Fetch `Headers` type — never Hono or `@hono/node-server` — so it stays reusable by Core's own + * identity composition without pulling an HTTP framework into Core; the Node/Hono transport glue + * that reads the actual socket peer and rewrites the request lives in `apps/api` instead + * (`apps/api/src/client-address.ts`). + * + * Direct mode trusts only the actual immediate socket peer — never any request header. Trusted-proxy + * mode trusts the configured forwarded header's content only when the actual immediate socket peer + * matches a configured trusted-proxy address/range; otherwise it falls back to direct-mode behavior + * (the peer address itself), exactly as if no forwarded header existed at all. A forwarded chain is + * walked right to left: trailing hops that match a trusted proxy are skipped, and the first hop from + * the right that does not match is the resolved client address. A chain that is missing, empty, + * contains any malformed token, or consists entirely of trusted-proxy hops (no genuine client + * address left to resolve) fails closed rather than guessing — never a caller-chosen bucket, and + * never a silent collapse onto the proxy's own address. + */ +export function createClientAddressResolver(config: ClientAddressConfig): ClientAddressResolver { + const isTrustedProxy = + config.mode === "trusted-proxy" ? buildTrustedProxyMatcher(config.trustedProxies) : undefined; + + return { + resolve({ peerAddress, headers }): ClientAddressResolution { + const normalizedPeer = + peerAddress === undefined ? undefined : normalizeClientAddress(peerAddress); + if (!normalizedPeer) { + return { ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" }; + } + + if (config.mode === "direct" || !isTrustedProxy?.(normalizedPeer)) { + return { ok: true, address: normalizedPeer }; + } + + const headerValue = headers.get(config.header); + if (headerValue === null) { + return { ok: false, reason: "FORWARDED_HEADER_MISSING" }; + } + const tokens = headerValue.split(",").map((token) => token.trim()); + if (tokens.length === 0 || tokens.some((token) => token.length === 0)) { + return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" }; + } + const normalizedTokens: string[] = []; + for (const token of tokens) { + const normalized = normalizeClientAddress(token); + if (!normalized) { + return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" }; + } + normalizedTokens.push(normalized); + } + for (let index = normalizedTokens.length - 1; index >= 0; index -= 1) { + const candidate = normalizedTokens[index]; + if (candidate !== undefined && !isTrustedProxy(candidate)) { + return { ok: true, address: candidate }; + } + } + return { ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" }; + }, + }; +} diff --git a/core/src/identity/config.ts b/core/src/identity/config.ts index 48485ef..57309ee 100644 --- a/core/src/identity/config.ts +++ b/core/src/identity/config.ts @@ -5,6 +5,7 @@ export type IdentityConfigErrorCode = | "BASE_URL_INVALID" | "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_MISSING" + | "PUBLIC_SIGNUP_ENABLED_INVALID" | "SECRET_LOW_ENTROPY" | "SECRET_MISSING" | "SECRET_TOO_SHORT" @@ -29,6 +30,8 @@ const errorMessages: Readonly> = Object. BASE_URL_LOOPBACK_IN_PRODUCTION: "The identity base URL must not resolve to a loopback host in production.", BASE_URL_MISSING: "The identity base URL is required.", + PUBLIC_SIGNUP_ENABLED_INVALID: + 'Public signup enablement must be an explicit boolean or the exact string "true"/"false".', SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.", SECRET_MISSING: "The identity secret is required.", SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`, @@ -61,12 +64,18 @@ export class IdentityConfigError extends Error { export interface IdentityConfig { readonly baseUrl: string; + /** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) — + * public signup never becomes reachable merely because this input was omitted, misspelled, or + * loosely parsed. Development and test composition may set this explicitly; production + * composition must set it explicitly too, since there is no environment-sensitive default. */ + readonly publicSignupEnabled: boolean; readonly secret: string; readonly trustedOrigins: readonly string[]; } export interface IdentityConfigInput { readonly baseUrl?: unknown; + readonly publicSignupEnabled?: unknown; readonly secret?: unknown; readonly trustedOrigins?: unknown; } @@ -153,6 +162,29 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment return origin; } +/** + * Strict, explicit-only parsing: `undefined` is the one safe default (disabled), a genuine boolean + * passes straight through for programmatic callers, and the exact lowercase strings `"true"`/ + * `"false"` are accepted so an environment variable (always a string) can opt in — anything else + * (a misspelling, `"1"`, `"yes"`, mixed case, an empty string) is rejected outright rather than + * silently coerced to `false` or `true`. There is deliberately no "loosely truthy" branch. + */ +function validatePublicSignupEnabled(value: unknown): boolean { + if (value === undefined) { + return false; + } + if (typeof value === "boolean") { + return value; + } + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + throw new IdentityConfigError("PUBLIC_SIGNUP_ENABLED_INVALID"); +} + function validateTrustedOrigins( trustedOrigins: unknown, environment: IdentityEnvironment, @@ -179,6 +211,7 @@ export function createIdentityConfig( const secret = validateSecret(input.secret); const baseUrl = validateBaseUrl(input.baseUrl, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); + const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled); - return Object.freeze({ baseUrl, secret, trustedOrigins }); + return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins }); } diff --git a/core/src/identity/index.ts b/core/src/identity/index.ts index 843a30d..b943617 100644 --- a/core/src/identity/index.ts +++ b/core/src/identity/index.ts @@ -1,3 +1,4 @@ +export * from "./client-address.js"; export * from "./config.js"; export * from "./database.js"; export * from "./runtime.js"; diff --git a/core/src/identity/runtime.ts b/core/src/identity/runtime.ts index 723b2df..43b64c4 100644 --- a/core/src/identity/runtime.ts +++ b/core/src/identity/runtime.ts @@ -23,6 +23,18 @@ const runtimeErrorMessages: Readonly> = const identityDatabaseProbeTimeoutMs = 3_000; +/** + * The one header name Better Auth's own client-address resolution (`advanced.ipAddress. + * ipAddressHeaders`) is configured to trust — never `X-Forwarded-For`, `Forwarded`, + * `CF-Connecting-IP`, `X-Real-IP`, or any other caller-reachable header. Core never reinterprets an + * untrusted proxy header itself: `apps/api`'s transport boundary (`apps/api/src/client-address.ts`) + * is the only writer, resolving the real socket peer (direct mode) or a chain walked against an + * explicitly configured trusted-proxy set (proxy mode) before overwriting any externally supplied + * copy of this exact header with its own server-derived result. Better Auth is configured to trust + * only this one synthesized value, never a second, competing header name. + */ +export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"; + /** * A fixed, generic replacement for whatever Better Auth actually logged. Better Auth's own log * call sites are not a stable contract Core can audit line by line, and several of them pass a @@ -149,6 +161,21 @@ export function createIdentityRuntime( // is enforced in every environment, including under Vitest. disableCSRFCheck: false, disableOriginCheck: false, + // The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/ + // ip`) only ever reads request headers — it has no way to see the actual TCP socket peer. + // Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than + // leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no + // proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named + // header is populated only by `apps/api`'s transport boundary, after it has already + // resolved and verified the caller's address against the actual socket peer (and, in + // explicit proxy mode, a configured trusted-proxy chain) — see + // `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that + // option only inspects header *content* for proxy-shaped addresses and can never verify who + // actually sent the request, so the equivalent trust decision is made once, correctly, at + // the transport boundary instead of asked of Better Auth a second time. + ipAddress: { + ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER], + }, }, baseURL: options.config.baseUrl, database: drizzleAdapter(options.db, { @@ -157,7 +184,13 @@ export function createIdentityRuntime( }), emailAndPassword: { autoSignIn: false, - disableSignUp: false, + // Public registration is disabled unless explicitly enabled (`IdentityConfig. + // publicSignupEnabled`, `core/src/identity/config.ts`) — never a side effect of an absent, + // misspelled, or loosely parsed configuration value. Better Auth's own `disableSignUp` gate + // rejects every `/sign-up/email` call with a fixed `400 EMAIL_PASSWORD_SIGN_UP_DISABLED` + // before ever looking up the email, so a disabled deployment never reveals whether an + // address is already registered; sign-in and existing sessions are entirely unaffected. + disableSignUp: !options.config.publicSignupEnabled, enabled: true, // Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // in the pinned release): an implicit default can change silently on a future Better Auth diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 7a11f49..3520f3d 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -96,6 +96,8 @@ Identity ○ Identity configuration skipped Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification). Identity runtime configuration (signing key, base URL, trusted origins) is not set. + ✓ Direct client-address mode + Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted. Object storage ○ Check unavailable diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 1a76ac5..e330d4f 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -209,6 +209,7 @@ describe("Ekklesia Doctor diagnostics", () => { "postgresql.connection", "postgresql.plan", "identity.configuration", + "identity.client-address", "object-storage.integration", ]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); @@ -613,6 +614,137 @@ describe("Ekklesia Doctor diagnostics", () => { expect(JSON.stringify(report)).not.toContain(hostileSecret); }); + it("passes identity configuration and reports public signup disabled by default", async () => { + const report = await diagnoseEkklesia( + { + ...options(), + identityConfiguration: { + baseUrl: "https://identity.example.test", + environment: "production", + secret: "correct-horse-battery-staple-0123456789", + trustedOrigins: ["https://identity.example.test"], + }, + }, + dependencies(), + ); + + const identity = check(report, "identity.configuration"); + expect(identity.status).toBe("pass"); + expect(identity.details.some((detail) => detail.includes("disabled (safe default)"))).toBe( + true, + ); + }); + + it("reports public signup as explicitly enabled when configured", async () => { + const report = await diagnoseEkklesia( + { + ...options(), + identityConfiguration: { + baseUrl: "https://identity.example.test", + environment: "production", + publicSignupEnabled: "true", + secret: "correct-horse-battery-staple-0123456789", + trustedOrigins: ["https://identity.example.test"], + }, + }, + dependencies(), + ); + + const identity = check(report, "identity.configuration"); + expect(identity.status).toBe("pass"); + expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true); + }); + + it("fails identity configuration for a misspelled public-signup value, never silently disabling or enabling it", async () => { + const report = await diagnoseEkklesia( + { + ...options(), + identityConfiguration: { + baseUrl: "https://identity.example.test", + environment: "production", + publicSignupEnabled: "yes", + secret: "correct-horse-battery-staple-0123456789", + trustedOrigins: ["https://identity.example.test"], + }, + }, + dependencies(), + ); + + const identity = check(report, "identity.configuration"); + expect(identity.status).toBe("failure"); + expect( + identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_ENABLED_INVALID")), + ).toBe(true); + expect(report.result).toBe("not-ready"); + }); + + it("passes client-address configuration with direct mode when nothing is configured", async () => { + const report = await diagnoseEkklesia(options(), dependencies()); + + const clientAddress = check(report, "identity.client-address"); + expect(clientAddress.status).toBe("pass"); + expect(clientAddress.summary).toContain("Direct"); + expect(report.result).toBe("ready-with-warnings"); + }); + + it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => { + const report = await diagnoseEkklesia( + { + ...options(), + clientAddress: { + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: ["10.0.0.1", "10.0.0.2"], + }, + }, + dependencies(), + ); + + const clientAddress = check(report, "identity.client-address"); + expect(clientAddress.status).toBe("pass"); + expect(clientAddress.summary).toContain("Trusted-proxy"); + expect(clientAddress.details.some((detail) => detail.includes("x-forwarded-for"))).toBe(true); + }); + + it("fails client-address configuration for an invalid trusted-proxy entry without leaking it", async () => { + const hostileEntry = "not-an-ip-secret-value"; + const report = await diagnoseEkklesia( + { + ...options(), + clientAddress: { + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: [hostileEntry], + }, + }, + dependencies(), + ); + + const clientAddress = check(report, "identity.client-address"); + expect(clientAddress.status).toBe("failure"); + expect( + clientAddress.details.some((detail) => + detail.includes("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"), + ), + ).toBe(true); + expect(JSON.stringify(report)).not.toContain(hostileEntry); + expect(report.result).toBe("not-ready"); + }); + + it("fails client-address configuration for an incomplete trusted-proxy mode (missing header)", async () => { + const report = await diagnoseEkklesia( + { + ...options(), + clientAddress: { mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] }, + }, + dependencies(), + ); + + const clientAddress = check(report, "identity.client-address"); + expect(clientAddress.status).toBe("failure"); + expect(report.result).toBe("not-ready"); + }); + it("never constructs a migration database or Better Auth merely to check identity configuration", async () => { const services = dependencies(); await diagnoseEkklesia( diff --git a/core/tests/identity-client-address.test.ts b/core/tests/identity-client-address.test.ts new file mode 100644 index 0000000..598ca6a --- /dev/null +++ b/core/tests/identity-client-address.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; +import { + ClientAddressConfigError, + createClientAddressConfig, + createClientAddressResolver, + findInvalidTrustedProxies, + normalizeClientAddress, +} from "../src/identity/client-address.js"; + +const INTERNAL_HEADER = "x-ekklesia-client-address"; + +describe("createClientAddressConfig", () => { + it("defaults to direct mode when mode is entirely omitted", () => { + expect(createClientAddressConfig()).toEqual({ mode: "direct" }); + expect(createClientAddressConfig({})).toEqual({ mode: "direct" }); + expect(Object.isFrozen(createClientAddressConfig())).toBe(true); + }); + + it("rejects direct mode combined with a header or trusted proxies", () => { + expect(() => createClientAddressConfig({ header: "x-forwarded-for", mode: "direct" })).toThrow( + ClientAddressConfigError, + ); + try { + createClientAddressConfig({ mode: "direct", trustedProxies: ["10.0.0.1"] }); + } catch (error) { + expect(error).toMatchObject({ code: "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION" }); + } + }); + + it("rejects an invalid mode value", () => { + let captured: unknown; + try { + createClientAddressConfig({ mode: "proxy" }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_MODE_INVALID" }); + }); + + it("accepts a valid trusted-proxy configuration", () => { + const config = createClientAddressConfig({ + header: "X-Forwarded-For", + mode: "trusted-proxy", + trustedProxies: ["10.0.0.5", "192.168.1.0/24"], + }); + expect(config).toEqual({ + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: ["10.0.0.5", "192.168.1.0/24"], + }); + expect(Object.isFrozen(config)).toBe(true); + if (config.mode === "trusted-proxy") { + expect(Object.isFrozen(config.trustedProxies)).toBe(true); + } + }); + + it("rejects trusted-proxy mode missing a header", () => { + let captured: unknown; + try { + createClientAddressConfig({ mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_MISSING" }); + }); + + it("rejects an invalid header name", () => { + let captured: unknown; + try { + createClientAddressConfig({ + header: "not a header!", + mode: "trusted-proxy", + trustedProxies: ["10.0.0.1"], + }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" }); + }); + + it("rejects the internal platform header as a configured source header", () => { + let captured: unknown; + try { + createClientAddressConfig( + { + header: INTERNAL_HEADER, + mode: "trusted-proxy", + trustedProxies: ["10.0.0.1"], + }, + INTERNAL_HEADER, + ); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" }); + }); + + it("rejects trusted-proxy mode with an empty trusted-proxy list", () => { + let captured: unknown; + try { + createClientAddressConfig({ + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: [], + }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY" }); + }); + + it("rejects a malformed trusted-proxy entry", () => { + for (const trustedProxies of [ + ["not-an-ip"], + ["10.0.0.1/33"], + ["10.0.0.1/-1"], + ["10.0.0.1/1e3"], + [""], + ]) { + let captured: unknown; + try { + createClientAddressConfig({ + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies, + }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID" }); + } + }); + + it("never leaks the offending mode, header, or trusted-proxy value in the thrown error", () => { + const hostileHeader = "x-hostile-secret-header"; + let captured: unknown; + try { + createClientAddressConfig({ + header: hostileHeader, + mode: "trusted-proxy", + trustedProxies: ["definitely-not-an-ip"], + }); + } catch (error) { + captured = error; + } + const publicText = `${String(captured)}\n${ + captured instanceof Error ? (captured.stack ?? "") : "" + }`; + expect(publicText).not.toContain(hostileHeader); + expect(publicText).not.toContain("definitely-not-an-ip"); + }); +}); + +describe("findInvalidTrustedProxies", () => { + it("identifies exactly the malformed entries", () => { + expect( + findInvalidTrustedProxies(["10.0.0.1", "not-an-ip", "192.168.0.0/24", "10.0.0.1/99"]), + ).toEqual(["not-an-ip", "10.0.0.1/99"]); + }); + + it("accepts IPv6 addresses and ranges", () => { + expect(findInvalidTrustedProxies(["::1", "fd00::/8", "2001:db8::1"])).toEqual([]); + }); +}); + +describe("normalizeClientAddress", () => { + it("passes a plain IPv4 address through unchanged", () => { + expect(normalizeClientAddress("192.0.2.10")).toBe("192.0.2.10"); + }); + + it("lowercases a plain IPv6 address", () => { + expect(normalizeClientAddress("2001:DB8::1")).toBe("2001:db8::1"); + }); + + it("collapses an IPv4-mapped IPv6 address to its embedded IPv4 form", () => { + expect(normalizeClientAddress("::ffff:127.0.0.1")).toBe("127.0.0.1"); + expect(normalizeClientAddress("::FFFF:203.0.113.5")).toBe("203.0.113.5"); + }); + + it("rejects a zone id as ambiguous", () => { + expect(normalizeClientAddress("fe80::1%eth0")).toBeUndefined(); + }); + + it("rejects malformed, empty, or non-IP input", () => { + for (const value of ["", " ", "not-an-ip", "999.999.999.999", "1.2.3"]) { + expect(normalizeClientAddress(value)).toBeUndefined(); + } + }); + + it("trims surrounding whitespace", () => { + expect(normalizeClientAddress(" 192.0.2.10 ")).toBe("192.0.2.10"); + }); +}); + +describe("createClientAddressResolver — direct mode", () => { + it("resolves the actual immediate socket peer, ignoring any forwarded header", () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.99" }), + peerAddress: "192.0.2.1", + }); + expect(result).toEqual({ address: "192.0.2.1", ok: true }); + }); + + it("fails closed when no peer address is available", () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const result = resolver.resolve({ headers: new Headers(), peerAddress: undefined }); + expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" }); + }); + + it("fails closed when the peer address is not a valid IP", () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const result = resolver.resolve({ headers: new Headers(), peerAddress: "not-an-ip" }); + expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" }); + }); + + it("normalizes an IPv4-mapped IPv6 peer to its IPv4 form", () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const result = resolver.resolve({ headers: new Headers(), peerAddress: "::ffff:198.51.100.7" }); + expect(result).toEqual({ address: "198.51.100.7", ok: true }); + }); + + it("gives distinct real peer addresses distinct resolved identities", () => { + const resolver = createClientAddressResolver({ mode: "direct" }); + const first = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.2" }); + const second = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.3" }); + expect(first).toEqual({ address: "127.0.0.2", ok: true }); + expect(second).toEqual({ address: "127.0.0.3", ok: true }); + expect(first).not.toEqual(second); + }); +}); + +describe("createClientAddressResolver — trusted-proxy mode", () => { + function proxyResolver(trustedProxies: readonly string[] = ["10.0.0.1", "10.0.0.2"]) { + return createClientAddressResolver({ + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies, + }); + } + + it("ignores the forwarded header entirely when the immediate peer is not a trusted proxy", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.99" }), + peerAddress: "198.51.100.1", + }); + // Falls back to direct-mode behavior: the untrusted peer itself, never the header content. + expect(result).toEqual({ address: "198.51.100.1", ok: true }); + }); + + it("honors a single-value forwarded header from a trusted proxy", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ address: "203.0.113.5", ok: true }); + }); + + it("walks a chained forwarded header right-to-left, skipping trusted-proxy hops", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.2" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ address: "203.0.113.5", ok: true }); + }); + + it("walks past multiple trusted-proxy hops to find the genuine client", () => { + const resolver = proxyResolver(["10.0.0.1", "10.0.0.2", "10.0.0.3"]); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.3, 10.0.0.2" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ address: "203.0.113.5", ok: true }); + }); + + it("fails closed when the trusted proxy sends no forwarded header at all", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ headers: new Headers(), peerAddress: "10.0.0.1" }); + expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MISSING" }); + }); + + it("fails closed on an empty (but present) forwarded header value", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" }); + }); + + it("fails closed on a malformed token anywhere in the chain", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5, not-an-ip" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" }); + }); + + it("fails closed on an empty comma-separated segment (ambiguous multi-valued form)", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5,, 10.0.0.1" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" }); + }); + + it("fails closed when the entire chain resolves to trusted-proxy hops with no genuine client left", () => { + const resolver = proxyResolver(); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "10.0.0.2, 10.0.0.1" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" }); + }); + + it("normalizes an IPv4-mapped IPv6 hop before matching it against a trusted IPv4 proxy", () => { + const resolver = proxyResolver(["10.0.0.1"]); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5, ::ffff:10.0.0.1" }), + peerAddress: "10.0.0.1", + }); + expect(result).toEqual({ address: "203.0.113.5", ok: true }); + }); + + it("matches a trusted-proxy CIDR range, not only an exact address", () => { + const resolver = proxyResolver(["192.168.1.0/24"]); + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5" }), + peerAddress: "192.168.1.42", + }); + expect(result).toEqual({ address: "203.0.113.5", ok: true }); + }); + + it("never lets a spoofed header from an untrusted peer choose a client identity that collides with a trusted flow", () => { + const resolver = proxyResolver(); + // An attacker connecting directly (not through the real proxy) claims to forward from a + // trusted-proxy address itself — since the *immediate peer* is what gates trust, not header + // content, this must still resolve to the attacker's own real peer address. + const result = resolver.resolve({ + headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }), + peerAddress: "203.0.113.200", + }); + expect(result).toEqual({ address: "203.0.113.200", ok: true }); + }); +}); diff --git a/core/tests/identity-config.test.ts b/core/tests/identity-config.test.ts index a975d76..1080999 100644 --- a/core/tests/identity-config.test.ts +++ b/core/tests/identity-config.test.ts @@ -15,6 +15,7 @@ describe("Core identity configuration", () => { expect(config).toEqual({ baseUrl: validBaseUrl, + publicSignupEnabled: false, secret: validSecret, trustedOrigins: validTrustedOrigins, }); @@ -167,6 +168,41 @@ describe("Core identity configuration", () => { expect(config.baseUrl).toBe("http://localhost:3000"); }); + it("defaults publicSignupEnabled to false when entirely omitted", () => { + const config = createIdentityConfig(validInput(), { environment: "production" }); + expect(config.publicSignupEnabled).toBe(false); + }); + + it("accepts an explicit boolean publicSignupEnabled", () => { + expect( + createIdentityConfig({ ...validInput(), publicSignupEnabled: true }).publicSignupEnabled, + ).toBe(true); + expect( + createIdentityConfig({ ...validInput(), publicSignupEnabled: false }).publicSignupEnabled, + ).toBe(false); + }); + + it('accepts the exact strings "true"/"false" (environment variables are always strings)', () => { + expect( + createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" }).publicSignupEnabled, + ).toBe(true); + expect( + createIdentityConfig({ ...validInput(), publicSignupEnabled: "false" }).publicSignupEnabled, + ).toBe(false); + }); + + it("rejects a misspelled, loosely-truthy, or malformed publicSignupEnabled rather than silently defaulting", () => { + for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) { + let captured: unknown; + try { + createIdentityConfig({ ...validInput(), publicSignupEnabled }); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" }); + } + }); + it("never leaks the secret, base URL, or trusted origins through a thrown error", () => { const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef"; let captured: unknown; diff --git a/core/tests/identity-runtime.test.ts b/core/tests/identity-runtime.test.ts index 84c8eb0..e464b5e 100644 --- a/core/tests/identity-runtime.test.ts +++ b/core/tests/identity-runtime.test.ts @@ -9,14 +9,20 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); import type { IdentityConfig } from "../src/identity/config.js"; -import { createIdentityRuntime, IdentityRuntimeError } from "../src/identity/runtime.js"; +import { + createIdentityRuntime, + IdentityRuntimeError, + TRUSTED_CLIENT_ADDRESS_HEADER, +} from "../src/identity/runtime.js"; import { identityAdapterSchema } from "../src/identity/schema.js"; -function validConfig(): IdentityConfig { +function validConfig(overrides: Partial = {}): IdentityConfig { return Object.freeze({ baseUrl: "https://identity.example.test", + publicSignupEnabled: false, secret: "correct-horse-battery-staple-0123456789", trustedOrigins: Object.freeze(["https://identity.example.test"]), + ...overrides, }); } @@ -87,7 +93,7 @@ describe("Core identity runtime construction boundary", () => { ]); expect(options.emailAndPassword).toEqual({ autoSignIn: false, - disableSignUp: false, + disableSignUp: true, enabled: true, maxPasswordLength: 128, minPasswordLength: 8, @@ -98,13 +104,37 @@ describe("Core identity runtime construction boundary", () => { expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]); }); + it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => { + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + + createIdentityRuntime({ config: validConfig({ publicSignupEnabled: true }), db: fakeDb() }); + + const [options] = betterAuthMock.mock.calls[0] as [Record]; + expect(options.emailAndPassword).toMatchObject({ disableSignUp: false }); + }); + it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => { betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); createIdentityRuntime({ config: validConfig(), db: fakeDb() }); const [options] = betterAuthMock.mock.calls[0] as [Record]; - expect(options.advanced).toEqual({ disableCSRFCheck: false, disableOriginCheck: false }); + expect(options.advanced).toEqual({ + disableCSRFCheck: false, + disableOriginCheck: false, + ipAddress: { ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER] }, + }); + }); + + it("trusts exactly the one platform-owned client-address header, never Better Auth's own default", () => { + betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); + + createIdentityRuntime({ config: validConfig(), db: fakeDb() }); + + const [options] = betterAuthMock.mock.calls[0] as [Record]; + const advanced = options.advanced as { ipAddress?: { ipAddressHeaders?: string[] } }; + expect(advanced.ipAddress?.ipAddressHeaders).toEqual([TRUSTED_CLIENT_ADDRESS_HEADER]); + expect(advanced.ipAddress?.ipAddressHeaders).not.toContain("x-forwarded-for"); }); it("configures onAPIError.throw so an unexpected failure escapes Better Auth's own unsanitized fallback logger instead of being swallowed internally", () => { diff --git a/docs/development/api.md b/docs/development/api.md index 0ec7f99..c18bd47 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -11,15 +11,22 @@ timeouts, idempotent shutdown, and pinned password-length boundaries) without ch above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md). Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md). +Phase 5D pass 1 hardened this same public perimeter against an independent security audit: a +server-owned client-address resolution boundary for the rate limiter, public sign-up disabled by +default, an explicit request-body-size policy for every public route (including a module route's +own declared contract), and finite Node header/request timeouts — see +[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md). ## What this is, and is not -After this slice it is possible to start the API, publicly create an ordinary account, sign in, -resolve a request to an Ekklesia principal, and sign out. It is **not** possible to verify an email -address, recover a forgotten password, use any provider besides email/password, reach a Dashboard, -or hold any role or permission — none of that exists yet. **Public registration is not ready for a -real public launch after this PR**: there is no email delivery service, so an unverified email is -trusted as entered and a forgotten password has no recovery path. +After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia +principal, and sign out; public account creation is also possible once explicitly enabled (see +[Public registration policy](#public-registration-policy)). It is **not** possible to verify an +email address, recover a forgotten password, use any provider besides email/password, reach a +Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not +ready for a real public launch**: there is no email delivery service, so an unverified email is +trusted as entered and a forgotten password has no recovery path, and no durable, distributed +abuse control exists yet even with signup enabled. ## Starting the API @@ -48,6 +55,10 @@ No real values are shown here — configure per environment and never commit the | `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. | | `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. | | `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. | +| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional. Exactly `"true"` to enable public sign-up. Defaults to disabled — omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). | +| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). | +| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). | +| `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. | | `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. | ## Routes @@ -93,7 +104,22 @@ permission, membership, or volunteer data. ## Public registration policy -Anyone may create an account (`emailAndPassword.enabled: true`, `disableSignUp: false`). A +**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other +than exactly `"true"`). This is a strict, typed gate (`IdentityConfig.publicSignupEnabled`, +`core/src/identity/config.ts`) wired directly to Better Auth's own `emailAndPassword.disableSignUp` +flag (`core/src/identity/runtime.ts`) — there is no second, platform-owned signup gate, because the +pinned release's own mechanism already has every required property: every `POST +/api/auth/sign-up/email` call is rejected with a fixed, sanitized `400 +{"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever +looks up the email, so a disabled deployment never reveals whether an address is already +registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate. Set +`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly to enable it (development and test composition do +this routinely; production composition must do so deliberately, with the same audit trail as any +other configuration change). This pass adds no email verification, CAPTCHA, invitation, or durable +distributed abuse control — enabling public signup in production without those remains explicitly +not ready for a real public launch; see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md). + +Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A registered account is an **authentication identity** — proof that someone can present this email and password — not a church-membership record; it does not imply attendance or involvement, and nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every @@ -160,8 +186,9 @@ for the root-cause investigation and the full account of what changed. Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain` -attribute is set, so every cookie is host-only. Trusted proxy headers are not configured in this -slice — a deployment behind a reverse proxy is future work. +attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution) +for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from +CORS/CSRF above. ## Logging @@ -195,17 +222,61 @@ none of it reaches `console.error`, and separately asserts `onAPIError` is confi integration test forces a real identity-database failure through Better Auth's own sign-in handler and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process. +## Client-address resolution + +Better Auth's rate limiter (below) needs a trustworthy per-client identity, and the pinned +release's own client-address resolution only ever reads request headers — it cannot see the real +TCP socket peer. `apps/api` resolves and sanitizes that identity once, at the Node/API transport +boundary (`apps/api/src/client-address.ts`, wrapping the `fetch` callback passed to +`@hono/node-server`'s `serve()` in `apps/api/src/server.ts`), *before* Better Auth or any other +route ever sees the request. The resolution policy itself (`core/src/identity/client-address.ts`) +is framework-agnostic and lives in Core; only the Node-specific glue that reads +`env.incoming.socket.remoteAddress` lives in `apps/api`. + +- **Direct mode** (the default, `EKKLESIA_CLIENT_ADDRESS_MODE` unset or `"direct"`). Trusts only + the actual immediate socket peer. No `X-Forwarded-For`, `Forwarded`, `CF-Connecting-IP`, + `X-Real-IP`, or any other request header is ever consulted for identity. +- **Trusted-proxy mode** (`EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`, with + `EKKLESIA_CLIENT_ADDRESS_HEADER` and `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` both required). + A forwarded header's content is trusted **only** when the actual immediate socket peer matches + one of the configured trusted-proxy addresses/CIDR ranges; otherwise the peer address itself is + used, exactly as in direct mode. A comma-separated forwarded chain is walked **right to left**: + trailing hops matching a trusted proxy are skipped, and the first hop from the right that does + not match is the resolved client address. A missing, empty, malformed, or entirely-trusted-hops + chain from an otherwise-trusted peer fails closed with a sanitized + `503 {"code":"CLIENT_ADDRESS_UNRESOLVED"}` — Better Auth, Core, and every module handler are all + unreachable in that case. IPv4, IPv6, and IPv4-mapped IPv6 (`::ffff:a.b.c.d`, the form a + dual-stack socket or a real proxy actually emits) all normalize consistently before comparison. + +Whichever address is resolved is written into one fixed internal header +(`TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"`, `core/src/identity/runtime.ts`), +**overwriting** — never appending to — any externally supplied copy of that exact header name, so +a caller can never choose its own "resolved" identity by sending it directly. Better Auth is +configured to trust only this one header (`advanced.ipAddress.ipAddressHeaders`), never its own +default (`x-forwarded-for`) and never `trustedProxies` (that pinned-release option only inspects +header *content*, never who actually connected — see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md) +for why that is insufficient on its own). + +**A reverse proxy in front of this API must be configured to *overwrite*, not append to, the +configured forwarded header**, and the origin must not be reachable except through that proxy — +neither is something application-level validation can enforce; both are the deployment's own +network/firewall responsibility. `pnpm doctor`'s `identity.client-address` check distinguishes +three states: safe direct mode, an explicitly configured (and structurally valid) trusted-proxy +mode, and an invalid/contradictory configuration (which fails Doctor, not merely warns). + ## Rate limiting Better Auth's built-in rate limiting is always enabled, including by the executable entry point — it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the integration test suite injects a narrow, explicit override so it is not itself rate-limited by its -own many requests. Storage remains the default in-memory backend, which has two honest limitations: -it is **per-process** (no protection across multiple API instances or restarts), and because no -trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path -rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade -abuse-control design. +own many requests. Storage remains the default in-memory backend, which is **per-process**: no +protection across multiple API instances or restarts. Client identity for this limiter is now +always the resolved, server-owned address described above — never a caller-controlled header — so +rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh +bucket, and unrelated real peers are never collapsed into one shared bucket merely because no +header was present. This closes the rate-limit-identity gap; it does not add distributed storage — +a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5. ## Module discovery, permission composition, and module runtime startup @@ -369,8 +440,9 @@ runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never ## A real signup → sign-in → principal → sign-out transcript Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL -database. Session tokens, cookie values, and generated ids below are sanitized/truncated examples, -not real credentials. +database, with `EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly set (public sign-up is disabled by +default — see [Public registration policy](#public-registration-policy)). Session tokens, cookie +values, and generated ids below are sanitized/truncated examples, not real credentials. ```text POST /api/auth/sign-up/email