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 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 12:20:51 -05:00
co-authored by Claude Sonnet 5
parent 5e09330001
commit cafd513019
22 changed files with 1772 additions and 38 deletions
+12 -1
View File
@@ -119,7 +119,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
app.on(["POST", "GET"], "/api/auth/*", async (c) => { app.on(["POST", "GET"], "/api/auth/*", async (c) => {
try { 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) { } catch (error) {
return sanitizedErrorResponse(c, error, 500); return sanitizedErrorResponse(c, error, 500);
} }
+66
View File
@@ -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<Response>,
resolver: ClientAddressResolver,
): (request: Request, env: NodeServerBindings) => Promise<Response> {
return async (request: Request, env: NodeServerBindings): Promise<Response> => {
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);
};
}
+11 -1
View File
@@ -4,6 +4,11 @@ import {
type IdentityConfig, type IdentityConfig,
type IdentityConfigInput, type IdentityConfigInput,
} from "@ekklesia/core/identity"; } from "@ekklesia/core/identity";
import {
type ClientAddressConfig,
type ClientAddressConfigInput,
createClientAddressConfig,
} from "./client-address.js";
export type ApiConfigErrorCode = export type ApiConfigErrorCode =
| "DATABASE_URL_INVALID" | "DATABASE_URL_INVALID"
@@ -37,6 +42,7 @@ const defaultHost = "127.0.0.1";
const defaultPort = 3000; const defaultPort = 3000;
export interface ApiRuntimeConfig { export interface ApiRuntimeConfig {
readonly clientAddress: ClientAddressConfig;
readonly databaseUrl: string; readonly databaseUrl: string;
readonly host: string; readonly host: string;
readonly identity: IdentityConfig; readonly identity: IdentityConfig;
@@ -44,6 +50,9 @@ export interface ApiRuntimeConfig {
} }
export interface ApiRuntimeConfigInput { 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 databaseUrl?: unknown;
readonly host?: unknown; readonly host?: unknown;
readonly identity: IdentityConfigInput; readonly identity: IdentityConfigInput;
@@ -112,6 +121,7 @@ export function createApiRuntimeConfig(
input.identity, input.identity,
options.environment === undefined ? {} : { environment: options.environment }, 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 });
} }
+52 -8
View File
@@ -14,6 +14,12 @@ import {
import { serve, type ServerType } from "@hono/node-server"; import { serve, type ServerType } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; 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 ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { prepareStartupModuleInventory } from "./platform-permissions.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<ServerType> { /**
* 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<ServerType> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let settled = false; let settled = false;
const server = serve({ fetch: app.fetch, hostname: host, port }, () => { const server = serve(
if (!settled) { { fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver), hostname: host, port },
settled = true; () => {
resolve(server); if (!settled) {
} settled = true;
}); resolve(server);
}
},
);
server.once("error", () => { server.once("error", () => {
if (!settled) { if (!settled) {
settled = true; settled = true;
@@ -178,9 +198,11 @@ export async function startApi(
throw error; throw error;
} }
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
let server: ServerType; let server: ServerType;
try { try {
server = await bindServer(app, config.host, config.port); server = await bindServer(app, config.host, config.port, clientAddressResolver);
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
@@ -250,6 +272,25 @@ export function installShutdownSignalHandlers(
process.on("SIGTERM", handleSignal); 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(): { function readConfigFromEnvironment(): {
readonly input: Parameters<typeof createApiRuntimeConfig>[0]; readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production"; readonly environment: "development" | "production";
@@ -259,14 +300,17 @@ function readConfigFromEnvironment(): {
?.split(",") ?.split(",")
.map((origin) => origin.trim()) .map((origin) => origin.trim())
.filter((origin) => origin.length > 0); .filter((origin) => origin.length > 0);
const clientAddress = readClientAddressInputFromEnvironment();
return { return {
environment: process.env.NODE_ENV === "development" ? "development" : "production", environment: process.env.NODE_ENV === "development" ? "development" : "production",
input: { input: {
...(clientAddress === undefined ? {} : { clientAddress }),
databaseUrl: process.env.DATABASE_URL, databaseUrl: process.env.DATABASE_URL,
host: process.env.EKKLESIA_API_HOST, host: process.env.EKKLESIA_API_HOST,
identity: { identity: {
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL, baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: process.env.EKKLESIA_IDENTITY_SECRET, secret: process.env.EKKLESIA_IDENTITY_SECRET,
trustedOrigins, trustedOrigins,
}, },
+405 -4
View File
@@ -18,6 +18,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { Client, type QueryResultRow } from "pg"; import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest"; import { afterAll, describe, expect, it, vi } from "vitest";
import type { ClientAddressConfigInput } from "../src/client-address.js";
import { createApiRuntimeConfig } from "../src/config.js"; import { createApiRuntimeConfig } from "../src/config.js";
import { startApi, type RunningApi } from "../src/server.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}`; 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, url: string,
modulesDirectoryOverride?: string, options: StartConfiguredApiOptions = {},
): Promise<RunningApi> { ): Promise<RunningApi> {
const config = createApiRuntimeConfig( const config = createApiRuntimeConfig(
{ {
...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }),
databaseUrl: url, databaseUrl: url,
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: identityBaseUrl, baseUrl: identityBaseUrl,
publicSignupEnabled: options.publicSignupEnabled ?? true,
secret: "integration-test-secret-0123456789abcdefgh", secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin], trustedOrigins: [identityBaseUrl, dashboardOrigin],
}, },
@@ -342,13 +357,26 @@ async function startMigratedApi(
}, },
{ environment: "production" }, { 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<RunningApi> {
// Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets // 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 // 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 // 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 // permission composition at a real, disposable module fixture instead of this repository's own
// (empty) `modules/` directory. // (empty) `modules/` directory.
return startApi(config, { return startConfiguredApi(url, {
rateLimitOverride: { max: 1000, window: 1 },
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }), ...(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<string, string | string[] | undefined>;
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<RawResponse> {
return new Promise((resolve, reject) => {
const url = new URL("/api/auth/sign-in/email", origin);
const payload = JSON.stringify({
email: "[email protected]",
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", () => { describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
let database: { name: string; url: string }; let database: { name: string; url: string };
let running: RunningApi; let running: RunningApi;
@@ -798,6 +889,7 @@ describe.sequential("live API — startup and migration guarantees", () => {
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: identityBaseUrl, baseUrl: identityBaseUrl,
publicSignupEnabled: true,
secret: "integration-test-secret-0123456789abcdefgh", secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl], trustedOrigins: [identityBaseUrl],
}, },
@@ -1528,3 +1620,312 @@ describe.sequential("live API — installed module permission end-to-end", () =>
await dropDatabase(database.name); 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: "[email protected]",
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 = "[email protected]";
// 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("[email protected]");
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 = "[email protected]";
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: "[email protected]", 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);
});
});
+19
View File
@@ -207,6 +207,25 @@ describe("Ekklesia API Hono application", () => {
expect(JSON.stringify(body)).not.toContain("postgresql://"); 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 () => { it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
+113
View File
@@ -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<typeof wrapFetchWithClientAddress>[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: "[email protected]" }),
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: "[email protected]" }));
});
});
+30
View File
@@ -21,10 +21,12 @@ describe("API runtime configuration", () => {
const config = createApiRuntimeConfig(validInput(), { environment: "production" }); const config = createApiRuntimeConfig(validInput(), { environment: "production" });
expect(config).toEqual({ expect(config).toEqual({
clientAddress: { mode: "direct" },
databaseUrl: validInput().databaseUrl, databaseUrl: validInput().databaseUrl,
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: ["https://identity.example.test"], trustedOrigins: ["https://identity.example.test"],
}, },
@@ -131,6 +133,34 @@ describe("API runtime configuration", () => {
expect(config.identity.baseUrl).toBe("http://localhost:3000"); 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", () => { it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia"; const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
let captured: unknown; let captured: unknown;
@@ -263,6 +263,7 @@ async function startMigratedApi(
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: identityBaseUrl, baseUrl: identityBaseUrl,
publicSignupEnabled: true,
secret: "integration-test-secret-0123456789abcdefgh", secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin], trustedOrigins: [identityBaseUrl, dashboardOrigin],
}, },
+19
View File
@@ -39,6 +39,7 @@ function requiredString(value: unknown): string {
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] { function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
const secret = process.env.EKKLESIA_IDENTITY_SECRET; const secret = process.env.EKKLESIA_IDENTITY_SECRET;
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL; 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 trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const trustedOrigins = trustedOriginsValue const trustedOrigins = trustedOriginsValue
? trustedOriginsValue ? trustedOriginsValue
@@ -50,11 +51,28 @@ function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConf
return { return {
...(baseUrl ? { baseUrl } : {}), ...(baseUrl ? { baseUrl } : {}),
environment: process.env.NODE_ENV === "development" ? "development" : "production", environment: process.env.NODE_ENV === "development" ? "development" : "production",
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
...(secret ? { secret } : {}), ...(secret ? { secret } : {}),
...(trustedOrigins ? { trustedOrigins } : {}), ...(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<number> { export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
const [rootValue, moduleSdkValue] = await Promise.all([ const [rootValue, moduleSdkValue] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")), readJson(path.join(repositoryRoot, "package.json")),
@@ -64,6 +82,7 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const moduleSdkPackage = rootPackageDocument(moduleSdkValue); const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
const report = await diagnoseEkklesia({ const report = await diagnoseEkklesia({
clientAddress: readClientAddressFromEnvironment(),
databaseUrl: process.env.DATABASE_URL, databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(), identityConfiguration: readIdentityConfigurationFromEnvironment(),
moduleDiscovery: { moduleDiscovery: {
+1 -1
View File
@@ -14,7 +14,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration"]), section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration"]), section("Object storage", ["object-storage.integration"]),
]); ]);
+52 -1
View File
@@ -7,11 +7,17 @@ import {
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm"; import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import {
type ClientAddressConfigInput,
ClientAddressConfigError,
createClientAddressConfig,
} from "../identity/client-address.js";
import { import {
type CreateIdentityConfigOptions, type CreateIdentityConfigOptions,
createIdentityConfig, createIdentityConfig,
IdentityConfigError, IdentityConfigError,
} from "../identity/config.js"; } from "../identity/config.js";
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
import { identityAdapterSchema } from "../identity/schema.js"; import { identityAdapterSchema } from "../identity/schema.js";
import { import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
@@ -44,6 +50,7 @@ export type DoctorCheckId =
| "postgresql.connection" | "postgresql.connection"
| "postgresql.plan" | "postgresql.plan"
| "identity.configuration" | "identity.configuration"
| "identity.client-address"
| "object-storage.integration"; | "object-storage.integration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning"; export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
@@ -64,11 +71,13 @@ export interface DoctorReport {
export interface DoctorIdentityConfigurationOptions { export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined; readonly baseUrl?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"]; readonly environment?: CreateIdentityConfigOptions["environment"];
readonly publicSignupEnabled?: string | undefined;
readonly secret?: string | undefined; readonly secret?: string | undefined;
readonly trustedOrigins?: readonly string[] | undefined; readonly trustedOrigins?: readonly string[] | undefined;
} }
export interface DoctorOptions { export interface DoctorOptions {
readonly clientAddress?: ClientAddressConfigInput | undefined;
readonly databaseUrl?: string | undefined; readonly databaseUrl?: string | undefined;
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions; readonly moduleDiscovery: ModuleDiscoveryOptions;
@@ -363,6 +372,7 @@ function identityConfigurationCheck(
const hasAnyValue = const hasAnyValue =
input?.secret !== undefined || input?.secret !== undefined ||
input?.baseUrl !== undefined || input?.baseUrl !== undefined ||
input?.publicSignupEnabled !== undefined ||
(input?.trustedOrigins?.length ?? 0) > 0; (input?.trustedOrigins?.length ?? 0) > 0;
if (!hasAnyValue) { if (!hasAnyValue) {
@@ -373,9 +383,10 @@ function identityConfigurationCheck(
} }
try { try {
createIdentityConfig( const config = createIdentityConfig(
{ {
baseUrl: input?.baseUrl, baseUrl: input?.baseUrl,
publicSignupEnabled: input?.publicSignupEnabled,
secret: input?.secret, secret: input?.secret,
trustedOrigins: input?.trustedOrigins, trustedOrigins: input?.trustedOrigins,
}, },
@@ -383,6 +394,9 @@ function identityConfigurationCheck(
); );
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [ return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail, schemaDetail,
config.publicSignupEnabled
? "Public signup is explicitly ENABLED."
: "Public signup is disabled (safe default).",
]); ]);
} catch (error) { } catch (error) {
const code = 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] { function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
return [ return [
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]), freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
@@ -548,6 +597,7 @@ export async function diagnoseEkklesia(
} }
const identityCheck = identityConfigurationCheck(options.identityConfiguration); const identityCheck = identityConfigurationCheck(options.identityConfiguration);
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [ const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
"Core storage integration has not been implemented yet.", "Core storage integration has not been implemented yet.",
@@ -563,6 +613,7 @@ export async function diagnoseEkklesia(
postgresConnectionCheck, postgresConnectionCheck,
postgresPlanCheck, postgresPlanCheck,
identityCheck, identityCheck,
clientAddressDoctorCheck,
storageCheck, storageCheck,
]); ]);
} }
+280
View File
@@ -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<Record<ClientAddressConfigErrorCode, string>> =
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" };
},
};
}
+34 -1
View File
@@ -5,6 +5,7 @@ export type IdentityConfigErrorCode =
| "BASE_URL_INVALID" | "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING" | "BASE_URL_MISSING"
| "PUBLIC_SIGNUP_ENABLED_INVALID"
| "SECRET_LOW_ENTROPY" | "SECRET_LOW_ENTROPY"
| "SECRET_MISSING" | "SECRET_MISSING"
| "SECRET_TOO_SHORT" | "SECRET_TOO_SHORT"
@@ -29,6 +30,8 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
BASE_URL_LOOPBACK_IN_PRODUCTION: BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.", "The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.", 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_LOW_ENTROPY: "The identity secret does not have sufficient entropy.",
SECRET_MISSING: "The identity secret is required.", SECRET_MISSING: "The identity secret is required.",
SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`, 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 { export interface IdentityConfig {
readonly baseUrl: string; 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 secret: string;
readonly trustedOrigins: readonly string[]; readonly trustedOrigins: readonly string[];
} }
export interface IdentityConfigInput { export interface IdentityConfigInput {
readonly baseUrl?: unknown; readonly baseUrl?: unknown;
readonly publicSignupEnabled?: unknown;
readonly secret?: unknown; readonly secret?: unknown;
readonly trustedOrigins?: unknown; readonly trustedOrigins?: unknown;
} }
@@ -153,6 +162,29 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment
return origin; 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( function validateTrustedOrigins(
trustedOrigins: unknown, trustedOrigins: unknown,
environment: IdentityEnvironment, environment: IdentityEnvironment,
@@ -179,6 +211,7 @@ export function createIdentityConfig(
const secret = validateSecret(input.secret); const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment); const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, 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 });
} }
+1
View File
@@ -1,3 +1,4 @@
export * from "./client-address.js";
export * from "./config.js"; export * from "./config.js";
export * from "./database.js"; export * from "./database.js";
export * from "./runtime.js"; export * from "./runtime.js";
+34 -1
View File
@@ -23,6 +23,18 @@ const runtimeErrorMessages: Readonly<Record<IdentityRuntimeErrorCode, string>> =
const identityDatabaseProbeTimeoutMs = 3_000; 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 * 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 * 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. // is enforced in every environment, including under Vitest.
disableCSRFCheck: false, disableCSRFCheck: false,
disableOriginCheck: 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, baseURL: options.config.baseUrl,
database: drizzleAdapter(options.db, { database: drizzleAdapter(options.db, {
@@ -157,7 +184,13 @@ export function createIdentityRuntime(
}), }),
emailAndPassword: { emailAndPassword: {
autoSignIn: false, 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, enabled: true,
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // 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 // in the pinned release): an implicit default can change silently on a future Better Auth
+2
View File
@@ -96,6 +96,8 @@ Identity
○ Identity configuration skipped ○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification). 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. 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 Object storage
○ Check unavailable ○ Check unavailable
+132
View File
@@ -209,6 +209,7 @@ describe("Ekklesia Doctor diagnostics", () => {
"postgresql.connection", "postgresql.connection",
"postgresql.plan", "postgresql.plan",
"identity.configuration", "identity.configuration",
"identity.client-address",
"object-storage.integration", "object-storage.integration",
]); ]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); 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); 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 () => { it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
const services = dependencies(); const services = dependencies();
await diagnoseEkklesia( await diagnoseEkklesia(
+350
View File
@@ -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 });
});
});
+36
View File
@@ -15,6 +15,7 @@ describe("Core identity configuration", () => {
expect(config).toEqual({ expect(config).toEqual({
baseUrl: validBaseUrl, baseUrl: validBaseUrl,
publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: validTrustedOrigins, trustedOrigins: validTrustedOrigins,
}); });
@@ -167,6 +168,41 @@ describe("Core identity configuration", () => {
expect(config.baseUrl).toBe("http://localhost:3000"); 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", () => { it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef"; const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
let captured: unknown; let captured: unknown;
+34 -4
View File
@@ -9,14 +9,20 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
import type { IdentityConfig } from "../src/identity/config.js"; 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"; import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(): IdentityConfig { function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
return Object.freeze({ return Object.freeze({
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
publicSignupEnabled: false,
secret: "correct-horse-battery-staple-0123456789", secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]), trustedOrigins: Object.freeze(["https://identity.example.test"]),
...overrides,
}); });
} }
@@ -87,7 +93,7 @@ describe("Core identity runtime construction boundary", () => {
]); ]);
expect(options.emailAndPassword).toEqual({ expect(options.emailAndPassword).toEqual({
autoSignIn: false, autoSignIn: false,
disableSignUp: false, disableSignUp: true,
enabled: true, enabled: true,
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, minPasswordLength: 8,
@@ -98,13 +104,37 @@ describe("Core identity runtime construction boundary", () => {
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]); 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<string, unknown>];
expect(options.emailAndPassword).toMatchObject({ disableSignUp: false });
});
it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => { it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig(), db: fakeDb() }); createIdentityRuntime({ config: validConfig(), db: fakeDb() });
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
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<string, unknown>];
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", () => { it("configures onAPIError.throw so an unexpected failure escapes Better Auth's own unsanitized fallback logger instead of being swallowed internally", () => {
+88 -16
View File
@@ -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). 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 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). 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 ## What this is, and is not
After this slice it is possible to start the API, publicly create an ordinary account, sign in, After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia
resolve a request to an Ekklesia principal, and sign out. It is **not** possible to verify an email principal, and sign out; public account creation is also possible once explicitly enabled (see
address, recover a forgotten password, use any provider besides email/password, reach a Dashboard, [Public registration policy](#public-registration-policy)). It is **not** possible to verify an
or hold any role or permission — none of that exists yet. **Public registration is not ready for a email address, recover a forgotten password, use any provider besides email/password, reach a
real public launch after this PR**: there is no email delivery service, so an unverified email is Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not
trusted as entered and a forgotten password has no recovery path. 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 ## 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_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_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_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. | | `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. |
## Routes ## Routes
@@ -93,7 +104,22 @@ permission, membership, or volunteer data.
## Public registration policy ## 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 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 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 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 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` 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 attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
slice — a deployment behind a reverse proxy is future work. for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
CORS/CSRF above.
## Logging ## 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 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. 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 ## Rate limiting
Better Auth's built-in rate limiting is always enabled, including by the executable entry point — 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 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 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 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: own many requests. Storage remains the default in-memory backend, which is **per-process**: no
it is **per-process** (no protection across multiple API instances or restarts), and because no protection across multiple API instances or restarts. Client identity for this limiter is now
trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path always the resolved, server-owned address described above — never a caller-controlled header — so
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh
abuse-control design. 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 ## 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 ## A real signup → sign-in → principal → sign-out transcript
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL 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, database, with `EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly set (public sign-up is disabled by
not real credentials. default — see [Public registration policy](#public-registration-policy)). Session tokens, cookie
values, and generated ids below are sanitized/truncated examples, not real credentials.
```text ```text
POST /api/auth/sign-up/email POST /api/auth/sign-up/email