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:
co-authored by
Claude Sonnet 5
parent
5e09330001
commit
cafd513019
+12
-1
@@ -119,7 +119,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
|
||||
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
|
||||
try {
|
||||
return await runtime.handleAuthRequest(c.req.raw);
|
||||
const response = await runtime.handleAuthRequest(c.req.raw);
|
||||
// Every Better Auth response — including a session cookie — must never be cached by an
|
||||
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
|
||||
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
|
||||
// and never reads or buffers the body.
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "no-store");
|
||||
return new Response(response.body, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
} catch (error) {
|
||||
return sanitizedErrorResponse(c, error, 500);
|
||||
}
|
||||
|
||||
@@ -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
@@ -4,6 +4,11 @@ import {
|
||||
type IdentityConfig,
|
||||
type IdentityConfigInput,
|
||||
} from "@ekklesia/core/identity";
|
||||
import {
|
||||
type ClientAddressConfig,
|
||||
type ClientAddressConfigInput,
|
||||
createClientAddressConfig,
|
||||
} from "./client-address.js";
|
||||
|
||||
export type ApiConfigErrorCode =
|
||||
| "DATABASE_URL_INVALID"
|
||||
@@ -37,6 +42,7 @@ const defaultHost = "127.0.0.1";
|
||||
const defaultPort = 3000;
|
||||
|
||||
export interface ApiRuntimeConfig {
|
||||
readonly clientAddress: ClientAddressConfig;
|
||||
readonly databaseUrl: string;
|
||||
readonly host: string;
|
||||
readonly identity: IdentityConfig;
|
||||
@@ -44,6 +50,9 @@ export interface ApiRuntimeConfig {
|
||||
}
|
||||
|
||||
export interface ApiRuntimeConfigInput {
|
||||
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
|
||||
* trust unless explicitly configured. See `./client-address.js`. */
|
||||
readonly clientAddress?: ClientAddressConfigInput;
|
||||
readonly databaseUrl?: unknown;
|
||||
readonly host?: unknown;
|
||||
readonly identity: IdentityConfigInput;
|
||||
@@ -112,6 +121,7 @@ export function createApiRuntimeConfig(
|
||||
input.identity,
|
||||
options.environment === undefined ? {} : { environment: options.environment },
|
||||
);
|
||||
const clientAddress = createClientAddressConfig(input.clientAddress);
|
||||
|
||||
return Object.freeze({ databaseUrl, host, identity, port });
|
||||
return Object.freeze({ clientAddress, databaseUrl, host, identity, port });
|
||||
}
|
||||
|
||||
+52
-8
@@ -14,6 +14,12 @@ import {
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
type ClientAddressResolver,
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
} from "./client-address.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
import { prepareStartupModuleInventory } from "./platform-permissions.js";
|
||||
@@ -61,15 +67,29 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function bindServer(app: Hono, host: string, port: number): Promise<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) => {
|
||||
let settled = false;
|
||||
const server = serve({ fetch: app.fetch, hostname: host, port }, () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(server);
|
||||
}
|
||||
});
|
||||
const server = serve(
|
||||
{ fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver), hostname: host, port },
|
||||
() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(server);
|
||||
}
|
||||
},
|
||||
);
|
||||
server.once("error", () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
@@ -178,9 +198,11 @@ export async function startApi(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
|
||||
|
||||
let server: ServerType;
|
||||
try {
|
||||
server = await bindServer(app, config.host, config.port);
|
||||
server = await bindServer(app, config.host, config.port, clientAddressResolver);
|
||||
} catch (error) {
|
||||
await safeStopModuleRuntime(moduleRuntime);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
@@ -250,6 +272,25 @@ export function installShutdownSignalHandlers(
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined {
|
||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||
if (mode === undefined) {
|
||||
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/
|
||||
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally
|
||||
// half-configuring proxy mode by setting only one of the three variables.
|
||||
return undefined;
|
||||
}
|
||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
||||
return {
|
||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
||||
mode,
|
||||
trustedProxies: trustedProxiesValue
|
||||
?.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
function readConfigFromEnvironment(): {
|
||||
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||
readonly environment: "development" | "production";
|
||||
@@ -259,14 +300,17 @@ function readConfigFromEnvironment(): {
|
||||
?.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0);
|
||||
const clientAddress = readClientAddressInputFromEnvironment();
|
||||
|
||||
return {
|
||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
input: {
|
||||
...(clientAddress === undefined ? {} : { clientAddress }),
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
host: process.env.EKKLESIA_API_HOST,
|
||||
identity: {
|
||||
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
||||
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
||||
trustedOrigins,
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
import type { ClientAddressConfigInput } from "../src/client-address.js";
|
||||
import { createApiRuntimeConfig } from "../src/config.js";
|
||||
import { startApi, type RunningApi } from "../src/server.js";
|
||||
|
||||
@@ -325,16 +326,30 @@ function apiOrigin(running: RunningApi): string {
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function startMigratedApi(
|
||||
interface StartConfiguredApiOptions {
|
||||
readonly clientAddress?: ClientAddressConfigInput;
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
/** Defaults to `true`: almost every existing scenario in this suite signs accounts up over HTTP
|
||||
* and needs it enabled. Pass `false` explicitly for the dedicated signup-gating scenarios. */
|
||||
readonly publicSignupEnabled?: boolean;
|
||||
/** Defaults to a generous override so this suite's own request volume never trips Better Auth's
|
||||
* strict built-in sign-in/sign-up rule. The dedicated rate-limit/client-address scenarios pass
|
||||
* `undefined` explicitly to exercise the real, unmodified production rule instead. */
|
||||
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
|
||||
}
|
||||
|
||||
async function startConfiguredApi(
|
||||
url: string,
|
||||
modulesDirectoryOverride?: string,
|
||||
options: StartConfiguredApiOptions = {},
|
||||
): Promise<RunningApi> {
|
||||
const config = createApiRuntimeConfig(
|
||||
{
|
||||
...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }),
|
||||
databaseUrl: url,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: options.publicSignupEnabled ?? true,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
@@ -342,13 +357,26 @@ async function startMigratedApi(
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
return startApi(config, {
|
||||
...("rateLimitOverride" in options
|
||||
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
||||
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
||||
...(options.modulesDirectoryOverride === undefined
|
||||
? {}
|
||||
: { modulesDirectoryOverride: options.modulesDirectoryOverride }),
|
||||
});
|
||||
}
|
||||
|
||||
async function startMigratedApi(
|
||||
url: string,
|
||||
modulesDirectoryOverride?: string,
|
||||
): Promise<RunningApi> {
|
||||
// Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets
|
||||
// either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise
|
||||
// reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point
|
||||
// permission composition at a real, disposable module fixture instead of this repository's own
|
||||
// (empty) `modules/` directory.
|
||||
return startApi(config, {
|
||||
rateLimitOverride: { max: 1000, window: 1 },
|
||||
return startConfiguredApi(url, {
|
||||
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
|
||||
});
|
||||
}
|
||||
@@ -456,6 +484,69 @@ function rawPostRequest(
|
||||
});
|
||||
}
|
||||
|
||||
interface RawSignInAttemptOptions {
|
||||
/** Originates the outbound connection from this local address — real, distinct loopback
|
||||
* addresses (127.0.0.2, 127.0.0.3, ...) are all independently routable on the loopback interface,
|
||||
* so this proves genuinely different real socket peers, not merely different header claims. */
|
||||
readonly localAddress?: string;
|
||||
/** A raw, unvalidated `X-Forwarded-For` header value to send — deliberately not parsed or
|
||||
* validated here, since these tests are proving how the *server* handles it. */
|
||||
readonly forwardedFor?: string;
|
||||
}
|
||||
|
||||
interface RawResponse {
|
||||
readonly body: string;
|
||||
readonly headers: Record<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", () => {
|
||||
let database: { name: string; url: string };
|
||||
let running: RunningApi;
|
||||
@@ -798,6 +889,7 @@ describe.sequential("live API — startup and migration guarantees", () => {
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: true,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl],
|
||||
},
|
||||
@@ -1528,3 +1620,312 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — public signup is disabled by default", () => {
|
||||
it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => {
|
||||
const database = await createDisposableDatabase("signup_disabled");
|
||||
await applyCoreMigrations(database.url);
|
||||
const running = await startConfiguredApi(database.url, { publicSignupEnabled: false });
|
||||
try {
|
||||
const origin = apiOrigin(running);
|
||||
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||
body: JSON.stringify({
|
||||
email: "[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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,6 +207,25 @@ describe("Ekklesia API Hono application", () => {
|
||||
expect(JSON.stringify(body)).not.toContain("postgresql://");
|
||||
});
|
||||
|
||||
it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })),
|
||||
});
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("auth-ok");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
|
||||
@@ -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]" }));
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,12 @@ describe("API runtime configuration", () => {
|
||||
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
|
||||
|
||||
expect(config).toEqual({
|
||||
clientAddress: { mode: "direct" },
|
||||
databaseUrl: validInput().databaseUrl,
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
publicSignupEnabled: false,
|
||||
secret: validSecret,
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
@@ -131,6 +133,34 @@ describe("API runtime configuration", () => {
|
||||
expect(config.identity.baseUrl).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("defaults public signup to disabled and threads an explicit enablement through to identity config", () => {
|
||||
expect(createApiRuntimeConfig(validInput()).identity.publicSignupEnabled).toBe(false);
|
||||
expect(
|
||||
createApiRuntimeConfig({
|
||||
...validInput(),
|
||||
identity: { ...validInput().identity, publicSignupEnabled: true },
|
||||
}).identity.publicSignupEnabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => {
|
||||
expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" });
|
||||
|
||||
const config = createApiRuntimeConfig({
|
||||
...validInput(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
},
|
||||
});
|
||||
expect(config.clientAddress).toEqual({
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
||||
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
||||
let captured: unknown;
|
||||
|
||||
@@ -263,6 +263,7 @@ async function startMigratedApi(
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: identityBaseUrl,
|
||||
publicSignupEnabled: true,
|
||||
secret: "integration-test-secret-0123456789abcdefgh",
|
||||
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ function requiredString(value: unknown): string {
|
||||
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
|
||||
const secret = process.env.EKKLESIA_IDENTITY_SECRET;
|
||||
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
|
||||
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED;
|
||||
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||
const trustedOrigins = trustedOriginsValue
|
||||
? trustedOriginsValue
|
||||
@@ -50,11 +51,28 @@ function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConf
|
||||
return {
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
|
||||
...(secret ? { secret } : {}),
|
||||
...(trustedOrigins ? { trustedOrigins } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] {
|
||||
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
|
||||
if (mode === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
|
||||
return {
|
||||
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
|
||||
mode,
|
||||
trustedProxies: trustedProxiesValue
|
||||
?.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
|
||||
const [rootValue, moduleSdkValue] = await Promise.all([
|
||||
readJson(path.join(repositoryRoot, "package.json")),
|
||||
@@ -64,6 +82,7 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
|
||||
const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
|
||||
|
||||
const report = await diagnoseEkklesia({
|
||||
clientAddress: readClientAddressFromEnvironment(),
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
identityConfiguration: readIdentityConfigurationFromEnvironment(),
|
||||
moduleDiscovery: {
|
||||
|
||||
@@ -14,7 +14,7 @@ const sections: readonly DoctorSection[] = Object.freeze([
|
||||
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
|
||||
section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
|
||||
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]),
|
||||
section("Identity", ["identity.configuration"]),
|
||||
section("Identity", ["identity.configuration", "identity.client-address"]),
|
||||
section("Object storage", ["object-storage.integration"]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
} from "@ekklesia/contracts";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
ClientAddressConfigError,
|
||||
createClientAddressConfig,
|
||||
} from "../identity/client-address.js";
|
||||
import {
|
||||
type CreateIdentityConfigOptions,
|
||||
createIdentityConfig,
|
||||
IdentityConfigError,
|
||||
} from "../identity/config.js";
|
||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../identity/schema.js";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
@@ -44,6 +50,7 @@ export type DoctorCheckId =
|
||||
| "postgresql.connection"
|
||||
| "postgresql.plan"
|
||||
| "identity.configuration"
|
||||
| "identity.client-address"
|
||||
| "object-storage.integration";
|
||||
|
||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||
@@ -64,11 +71,13 @@ export interface DoctorReport {
|
||||
export interface DoctorIdentityConfigurationOptions {
|
||||
readonly baseUrl?: string | undefined;
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
readonly publicSignupEnabled?: string | undefined;
|
||||
readonly secret?: string | undefined;
|
||||
readonly trustedOrigins?: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export interface DoctorOptions {
|
||||
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||
readonly databaseUrl?: string | undefined;
|
||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
@@ -363,6 +372,7 @@ function identityConfigurationCheck(
|
||||
const hasAnyValue =
|
||||
input?.secret !== undefined ||
|
||||
input?.baseUrl !== undefined ||
|
||||
input?.publicSignupEnabled !== undefined ||
|
||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||
|
||||
if (!hasAnyValue) {
|
||||
@@ -373,9 +383,10 @@ function identityConfigurationCheck(
|
||||
}
|
||||
|
||||
try {
|
||||
createIdentityConfig(
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
baseUrl: input?.baseUrl,
|
||||
publicSignupEnabled: input?.publicSignupEnabled,
|
||||
secret: input?.secret,
|
||||
trustedOrigins: input?.trustedOrigins,
|
||||
},
|
||||
@@ -383,6 +394,9 @@ function identityConfigurationCheck(
|
||||
);
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
config.publicSignupEnabled
|
||||
? "Public signup is explicitly ENABLED."
|
||||
: "Public signup is disabled (safe default).",
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
@@ -394,6 +408,41 @@ function identityConfigurationCheck(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure configuration validation only, mirroring `identityConfigurationCheck`: never resolves a real
|
||||
* peer address or reads a real request. Omitted configuration is a `pass`, not a skip — direct mode
|
||||
* (trusting only the actual immediate socket peer) is itself a complete, safe default, unlike
|
||||
* identity configuration, which has no meaningful default at all. An explicitly configured
|
||||
* trusted-proxy mode reports its header name and trusted-proxy count; an invalid or contradictory
|
||||
* combination is a `failure`, distinguishing the three states this pass's audit specifically calls
|
||||
* for: safe direct mode, explicitly configured proxy mode, and an invalid configuration.
|
||||
*/
|
||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||
try {
|
||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||
if (config.mode === "direct") {
|
||||
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||
]);
|
||||
}
|
||||
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof ClientAddressConfigError
|
||||
? error.code
|
||||
: "CLIENT_ADDRESS_CONFIGURATION_INVALID";
|
||||
return freezeCheck(
|
||||
"identity.client-address",
|
||||
"failure",
|
||||
"Client-address configuration invalid",
|
||||
[`[${code}] Configured client-address values failed validation.`],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
||||
@@ -548,6 +597,7 @@ export async function diagnoseEkklesia(
|
||||
}
|
||||
|
||||
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
||||
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
|
||||
|
||||
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
||||
"Core storage integration has not been implemented yet.",
|
||||
@@ -563,6 +613,7 @@ export async function diagnoseEkklesia(
|
||||
postgresConnectionCheck,
|
||||
postgresPlanCheck,
|
||||
identityCheck,
|
||||
clientAddressDoctorCheck,
|
||||
storageCheck,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export type IdentityConfigErrorCode =
|
||||
| "BASE_URL_INVALID"
|
||||
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
||||
| "BASE_URL_MISSING"
|
||||
| "PUBLIC_SIGNUP_ENABLED_INVALID"
|
||||
| "SECRET_LOW_ENTROPY"
|
||||
| "SECRET_MISSING"
|
||||
| "SECRET_TOO_SHORT"
|
||||
@@ -29,6 +30,8 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||
"The identity base URL must not resolve to a loopback host in production.",
|
||||
BASE_URL_MISSING: "The identity base URL is required.",
|
||||
PUBLIC_SIGNUP_ENABLED_INVALID:
|
||||
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
|
||||
SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.",
|
||||
SECRET_MISSING: "The identity secret is required.",
|
||||
SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`,
|
||||
@@ -61,12 +64,18 @@ export class IdentityConfigError extends Error {
|
||||
|
||||
export interface IdentityConfig {
|
||||
readonly baseUrl: string;
|
||||
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
|
||||
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
||||
* loosely parsed. Development and test composition may set this explicitly; production
|
||||
* composition must set it explicitly too, since there is no environment-sensitive default. */
|
||||
readonly publicSignupEnabled: boolean;
|
||||
readonly secret: string;
|
||||
readonly trustedOrigins: readonly string[];
|
||||
}
|
||||
|
||||
export interface IdentityConfigInput {
|
||||
readonly baseUrl?: unknown;
|
||||
readonly publicSignupEnabled?: unknown;
|
||||
readonly secret?: unknown;
|
||||
readonly trustedOrigins?: unknown;
|
||||
}
|
||||
@@ -153,6 +162,29 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment
|
||||
return origin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict, explicit-only parsing: `undefined` is the one safe default (disabled), a genuine boolean
|
||||
* passes straight through for programmatic callers, and the exact lowercase strings `"true"`/
|
||||
* `"false"` are accepted so an environment variable (always a string) can opt in — anything else
|
||||
* (a misspelling, `"1"`, `"yes"`, mixed case, an empty string) is rejected outright rather than
|
||||
* silently coerced to `false` or `true`. There is deliberately no "loosely truthy" branch.
|
||||
*/
|
||||
function validatePublicSignupEnabled(value: unknown): boolean {
|
||||
if (value === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (value === "true") {
|
||||
return true;
|
||||
}
|
||||
if (value === "false") {
|
||||
return false;
|
||||
}
|
||||
throw new IdentityConfigError("PUBLIC_SIGNUP_ENABLED_INVALID");
|
||||
}
|
||||
|
||||
function validateTrustedOrigins(
|
||||
trustedOrigins: unknown,
|
||||
environment: IdentityEnvironment,
|
||||
@@ -179,6 +211,7 @@ export function createIdentityConfig(
|
||||
const secret = validateSecret(input.secret);
|
||||
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
||||
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
||||
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled);
|
||||
|
||||
return Object.freeze({ baseUrl, secret, trustedOrigins });
|
||||
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
@@ -23,6 +23,18 @@ const runtimeErrorMessages: Readonly<Record<IdentityRuntimeErrorCode, string>> =
|
||||
|
||||
const identityDatabaseProbeTimeoutMs = 3_000;
|
||||
|
||||
/**
|
||||
* The one header name Better Auth's own client-address resolution (`advanced.ipAddress.
|
||||
* ipAddressHeaders`) is configured to trust — never `X-Forwarded-For`, `Forwarded`,
|
||||
* `CF-Connecting-IP`, `X-Real-IP`, or any other caller-reachable header. Core never reinterprets an
|
||||
* untrusted proxy header itself: `apps/api`'s transport boundary (`apps/api/src/client-address.ts`)
|
||||
* is the only writer, resolving the real socket peer (direct mode) or a chain walked against an
|
||||
* explicitly configured trusted-proxy set (proxy mode) before overwriting any externally supplied
|
||||
* copy of this exact header with its own server-derived result. Better Auth is configured to trust
|
||||
* only this one synthesized value, never a second, competing header name.
|
||||
*/
|
||||
export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
|
||||
|
||||
/**
|
||||
* A fixed, generic replacement for whatever Better Auth actually logged. Better Auth's own log
|
||||
* call sites are not a stable contract Core can audit line by line, and several of them pass a
|
||||
@@ -149,6 +161,21 @@ export function createIdentityRuntime(
|
||||
// is enforced in every environment, including under Vitest.
|
||||
disableCSRFCheck: false,
|
||||
disableOriginCheck: false,
|
||||
// The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/
|
||||
// ip`) only ever reads request headers — it has no way to see the actual TCP socket peer.
|
||||
// Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than
|
||||
// leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no
|
||||
// proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named
|
||||
// header is populated only by `apps/api`'s transport boundary, after it has already
|
||||
// resolved and verified the caller's address against the actual socket peer (and, in
|
||||
// explicit proxy mode, a configured trusted-proxy chain) — see
|
||||
// `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that
|
||||
// option only inspects header *content* for proxy-shaped addresses and can never verify who
|
||||
// actually sent the request, so the equivalent trust decision is made once, correctly, at
|
||||
// the transport boundary instead of asked of Better Auth a second time.
|
||||
ipAddress: {
|
||||
ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER],
|
||||
},
|
||||
},
|
||||
baseURL: options.config.baseUrl,
|
||||
database: drizzleAdapter(options.db, {
|
||||
@@ -157,7 +184,13 @@ export function createIdentityRuntime(
|
||||
}),
|
||||
emailAndPassword: {
|
||||
autoSignIn: false,
|
||||
disableSignUp: false,
|
||||
// Public registration is disabled unless explicitly enabled (`IdentityConfig.
|
||||
// publicSignupEnabled`, `core/src/identity/config.ts`) — never a side effect of an absent,
|
||||
// misspelled, or loosely parsed configuration value. Better Auth's own `disableSignUp` gate
|
||||
// rejects every `/sign-up/email` call with a fixed `400 EMAIL_PASSWORD_SIGN_UP_DISABLED`
|
||||
// before ever looking up the email, so a disabled deployment never reveals whether an
|
||||
// address is already registered; sign-in and existing sessions are entirely unaffected.
|
||||
disableSignUp: !options.config.publicSignupEnabled,
|
||||
enabled: true,
|
||||
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
|
||||
// in the pinned release): an implicit default can change silently on a future Better Auth
|
||||
|
||||
@@ -96,6 +96,8 @@ Identity
|
||||
○ Identity configuration skipped
|
||||
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
|
||||
Identity runtime configuration (signing key, base URL, trusted origins) is not set.
|
||||
✓ Direct client-address mode
|
||||
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
|
||||
|
||||
Object storage
|
||||
○ Check unavailable
|
||||
|
||||
@@ -209,6 +209,7 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
"postgresql.connection",
|
||||
"postgresql.plan",
|
||||
"identity.configuration",
|
||||
"identity.client-address",
|
||||
"object-storage.integration",
|
||||
]);
|
||||
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
|
||||
@@ -613,6 +614,137 @@ describe("Ekklesia Doctor diagnostics", () => {
|
||||
expect(JSON.stringify(report)).not.toContain(hostileSecret);
|
||||
});
|
||||
|
||||
it("passes identity configuration and reports public signup disabled by default", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details.some((detail) => detail.includes("disabled (safe default)"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("reports public signup as explicitly enabled when configured", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
publicSignupEnabled: "true",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("pass");
|
||||
expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fails identity configuration for a misspelled public-signup value, never silently disabling or enabling it", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
identityConfiguration: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
environment: "production",
|
||||
publicSignupEnabled: "yes",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const identity = check(report, "identity.configuration");
|
||||
expect(identity.status).toBe("failure");
|
||||
expect(
|
||||
identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_ENABLED_INVALID")),
|
||||
).toBe(true);
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("passes client-address configuration with direct mode when nothing is configured", async () => {
|
||||
const report = await diagnoseEkklesia(options(), dependencies());
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("pass");
|
||||
expect(clientAddress.summary).toContain("Direct");
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
});
|
||||
|
||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1", "10.0.0.2"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("pass");
|
||||
expect(clientAddress.summary).toContain("Trusted-proxy");
|
||||
expect(clientAddress.details.some((detail) => detail.includes("x-forwarded-for"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fails client-address configuration for an invalid trusted-proxy entry without leaking it", async () => {
|
||||
const hostileEntry = "not-an-ip-secret-value";
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: [hostileEntry],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("failure");
|
||||
expect(
|
||||
clientAddress.details.some((detail) =>
|
||||
detail.includes("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(report)).not.toContain(hostileEntry);
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("fails client-address configuration for an incomplete trusted-proxy mode (missing header)", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: { mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] },
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("failure");
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
|
||||
const services = dependencies();
|
||||
await diagnoseEkklesia(
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ describe("Core identity configuration", () => {
|
||||
|
||||
expect(config).toEqual({
|
||||
baseUrl: validBaseUrl,
|
||||
publicSignupEnabled: false,
|
||||
secret: validSecret,
|
||||
trustedOrigins: validTrustedOrigins,
|
||||
});
|
||||
@@ -167,6 +168,41 @@ describe("Core identity configuration", () => {
|
||||
expect(config.baseUrl).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
it("defaults publicSignupEnabled to false when entirely omitted", () => {
|
||||
const config = createIdentityConfig(validInput(), { environment: "production" });
|
||||
expect(config.publicSignupEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an explicit boolean publicSignupEnabled", () => {
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: true }).publicSignupEnabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: false }).publicSignupEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the exact strings "true"/"false" (environment variables are always strings)', () => {
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" }).publicSignupEnabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: "false" }).publicSignupEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a misspelled, loosely-truthy, or malformed publicSignupEnabled rather than silently defaulting", () => {
|
||||
for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
|
||||
}
|
||||
});
|
||||
|
||||
it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
|
||||
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
|
||||
let captured: unknown;
|
||||
|
||||
@@ -9,14 +9,20 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||
|
||||
import type { IdentityConfig } from "../src/identity/config.js";
|
||||
import { createIdentityRuntime, IdentityRuntimeError } from "../src/identity/runtime.js";
|
||||
import {
|
||||
createIdentityRuntime,
|
||||
IdentityRuntimeError,
|
||||
TRUSTED_CLIENT_ADDRESS_HEADER,
|
||||
} from "../src/identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../src/identity/schema.js";
|
||||
|
||||
function validConfig(): IdentityConfig {
|
||||
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
|
||||
return Object.freeze({
|
||||
baseUrl: "https://identity.example.test",
|
||||
publicSignupEnabled: false,
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,7 +93,7 @@ describe("Core identity runtime construction boundary", () => {
|
||||
]);
|
||||
expect(options.emailAndPassword).toEqual({
|
||||
autoSignIn: false,
|
||||
disableSignUp: false,
|
||||
disableSignUp: true,
|
||||
enabled: true,
|
||||
maxPasswordLength: 128,
|
||||
minPasswordLength: 8,
|
||||
@@ -98,13 +104,37 @@ describe("Core identity runtime construction boundary", () => {
|
||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||
});
|
||||
|
||||
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig({ publicSignupEnabled: true }), db: fakeDb() });
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(options.emailAndPassword).toMatchObject({ disableSignUp: false });
|
||||
});
|
||||
|
||||
it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<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", () => {
|
||||
|
||||
+88
-16
@@ -11,15 +11,22 @@ timeouts, idempotent shutdown, and pinned password-length boundaries) without ch
|
||||
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
|
||||
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
|
||||
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
|
||||
Phase 5D pass 1 hardened this same public perimeter against an independent security audit: a
|
||||
server-owned client-address resolution boundary for the rate limiter, public sign-up disabled by
|
||||
default, an explicit request-body-size policy for every public route (including a module route's
|
||||
own declared contract), and finite Node header/request timeouts — see
|
||||
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
|
||||
|
||||
## What this is, and is not
|
||||
|
||||
After this slice it is possible to start the API, publicly create an ordinary account, sign in,
|
||||
resolve a request to an Ekklesia principal, and sign out. It is **not** possible to verify an email
|
||||
address, recover a forgotten password, use any provider besides email/password, reach a Dashboard,
|
||||
or hold any role or permission — none of that exists yet. **Public registration is not ready for a
|
||||
real public launch after this PR**: there is no email delivery service, so an unverified email is
|
||||
trusted as entered and a forgotten password has no recovery path.
|
||||
After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia
|
||||
principal, and sign out; public account creation is also possible once explicitly enabled (see
|
||||
[Public registration policy](#public-registration-policy)). It is **not** possible to verify an
|
||||
email address, recover a forgotten password, use any provider besides email/password, reach a
|
||||
Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not
|
||||
ready for a real public launch**: there is no email delivery service, so an unverified email is
|
||||
trusted as entered and a forgotten password has no recovery path, and no durable, distributed
|
||||
abuse control exists yet even with signup enabled.
|
||||
|
||||
## Starting the API
|
||||
|
||||
@@ -48,6 +55,10 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
|
||||
| `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. |
|
||||
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
|
||||
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional. Exactly `"true"` to enable public sign-up. Defaults to disabled — omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
|
||||
| `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. |
|
||||
| `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. |
|
||||
|
||||
## Routes
|
||||
@@ -93,7 +104,22 @@ permission, membership, or volunteer data.
|
||||
|
||||
## Public registration policy
|
||||
|
||||
Anyone may create an account (`emailAndPassword.enabled: true`, `disableSignUp: false`). A
|
||||
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
|
||||
than exactly `"true"`). This is a strict, typed gate (`IdentityConfig.publicSignupEnabled`,
|
||||
`core/src/identity/config.ts`) wired directly to Better Auth's own `emailAndPassword.disableSignUp`
|
||||
flag (`core/src/identity/runtime.ts`) — there is no second, platform-owned signup gate, because the
|
||||
pinned release's own mechanism already has every required property: every `POST
|
||||
/api/auth/sign-up/email` call is rejected with a fixed, sanitized `400
|
||||
{"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever
|
||||
looks up the email, so a disabled deployment never reveals whether an address is already
|
||||
registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate. Set
|
||||
`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly to enable it (development and test composition do
|
||||
this routinely; production composition must do so deliberately, with the same audit trail as any
|
||||
other configuration change). This pass adds no email verification, CAPTCHA, invitation, or durable
|
||||
distributed abuse control — enabling public signup in production without those remains explicitly
|
||||
not ready for a real public launch; see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
|
||||
|
||||
Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A
|
||||
registered account is an **authentication identity** — proof that someone can present this email
|
||||
and password — not a church-membership record; it does not imply attendance or involvement, and
|
||||
nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every
|
||||
@@ -160,8 +186,9 @@ for the root-cause investigation and the full account of what changed.
|
||||
|
||||
Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the
|
||||
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
|
||||
attribute is set, so every cookie is host-only. Trusted proxy headers are not configured in this
|
||||
slice — a deployment behind a reverse proxy is future work.
|
||||
attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
|
||||
for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
|
||||
CORS/CSRF above.
|
||||
|
||||
## Logging
|
||||
|
||||
@@ -195,17 +222,61 @@ none of it reaches `console.error`, and separately asserts `onAPIError` is confi
|
||||
integration test forces a real identity-database failure through Better Auth's own sign-in handler
|
||||
and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process.
|
||||
|
||||
## Client-address resolution
|
||||
|
||||
Better Auth's rate limiter (below) needs a trustworthy per-client identity, and the pinned
|
||||
release's own client-address resolution only ever reads request headers — it cannot see the real
|
||||
TCP socket peer. `apps/api` resolves and sanitizes that identity once, at the Node/API transport
|
||||
boundary (`apps/api/src/client-address.ts`, wrapping the `fetch` callback passed to
|
||||
`@hono/node-server`'s `serve()` in `apps/api/src/server.ts`), *before* Better Auth or any other
|
||||
route ever sees the request. The resolution policy itself (`core/src/identity/client-address.ts`)
|
||||
is framework-agnostic and lives in Core; only the Node-specific glue that reads
|
||||
`env.incoming.socket.remoteAddress` lives in `apps/api`.
|
||||
|
||||
- **Direct mode** (the default, `EKKLESIA_CLIENT_ADDRESS_MODE` unset or `"direct"`). Trusts only
|
||||
the actual immediate socket peer. No `X-Forwarded-For`, `Forwarded`, `CF-Connecting-IP`,
|
||||
`X-Real-IP`, or any other request header is ever consulted for identity.
|
||||
- **Trusted-proxy mode** (`EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`, with
|
||||
`EKKLESIA_CLIENT_ADDRESS_HEADER` and `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` both required).
|
||||
A forwarded header's content is trusted **only** when the actual immediate socket peer matches
|
||||
one of the configured trusted-proxy addresses/CIDR ranges; otherwise the peer address itself is
|
||||
used, exactly as in direct mode. A comma-separated forwarded chain is walked **right to left**:
|
||||
trailing hops matching a trusted proxy are skipped, and the first hop from the right that does
|
||||
not match is the resolved client address. A missing, empty, malformed, or entirely-trusted-hops
|
||||
chain from an otherwise-trusted peer fails closed with a sanitized
|
||||
`503 {"code":"CLIENT_ADDRESS_UNRESOLVED"}` — Better Auth, Core, and every module handler are all
|
||||
unreachable in that case. IPv4, IPv6, and IPv4-mapped IPv6 (`::ffff:a.b.c.d`, the form a
|
||||
dual-stack socket or a real proxy actually emits) all normalize consistently before comparison.
|
||||
|
||||
Whichever address is resolved is written into one fixed internal header
|
||||
(`TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"`, `core/src/identity/runtime.ts`),
|
||||
**overwriting** — never appending to — any externally supplied copy of that exact header name, so
|
||||
a caller can never choose its own "resolved" identity by sending it directly. Better Auth is
|
||||
configured to trust only this one header (`advanced.ipAddress.ipAddressHeaders`), never its own
|
||||
default (`x-forwarded-for`) and never `trustedProxies` (that pinned-release option only inspects
|
||||
header *content*, never who actually connected — see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md)
|
||||
for why that is insufficient on its own).
|
||||
|
||||
**A reverse proxy in front of this API must be configured to *overwrite*, not append to, the
|
||||
configured forwarded header**, and the origin must not be reachable except through that proxy —
|
||||
neither is something application-level validation can enforce; both are the deployment's own
|
||||
network/firewall responsibility. `pnpm doctor`'s `identity.client-address` check distinguishes
|
||||
three states: safe direct mode, an explicitly configured (and structurally valid) trusted-proxy
|
||||
mode, and an invalid/contradictory configuration (which fails Doctor, not merely warns).
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Better Auth's built-in rate limiting is always enabled, including by the executable entry point —
|
||||
it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on
|
||||
sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the
|
||||
integration test suite injects a narrow, explicit override so it is not itself rate-limited by its
|
||||
own many requests. Storage remains the default in-memory backend, which has two honest limitations:
|
||||
it is **per-process** (no protection across multiple API instances or restarts), and because no
|
||||
trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path
|
||||
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade
|
||||
abuse-control design.
|
||||
own many requests. Storage remains the default in-memory backend, which is **per-process**: no
|
||||
protection across multiple API instances or restarts. Client identity for this limiter is now
|
||||
always the resolved, server-owned address described above — never a caller-controlled header — so
|
||||
rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh
|
||||
bucket, and unrelated real peers are never collapsed into one shared bucket merely because no
|
||||
header was present. This closes the rate-limit-identity gap; it does not add distributed storage —
|
||||
a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
|
||||
|
||||
## Module discovery, permission composition, and module runtime startup
|
||||
|
||||
@@ -369,8 +440,9 @@ runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never
|
||||
## A real signup → sign-in → principal → sign-out transcript
|
||||
|
||||
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
|
||||
database. Session tokens, cookie values, and generated ids below are sanitized/truncated examples,
|
||||
not real credentials.
|
||||
database, with `EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` explicitly set (public sign-up is disabled by
|
||||
default — see [Public registration policy](#public-registration-policy)). Session tokens, cookie
|
||||
values, and generated ids below are sanitized/truncated examples, not real credentials.
|
||||
|
||||
```text
|
||||
POST /api/auth/sign-up/email
|
||||
|
||||
Reference in New Issue
Block a user