fix: secure authentication request identity

Replace Better Auth's default trust of a caller-controlled X-Forwarded-For
with a platform-owned client-address resolution boundary: direct mode (the
safe default) trusts only the real socket peer, and an explicit trusted-proxy
mode requires a header name, a concrete trusted-proxy list, and only honors a
forwarded chain from an actual trusted peer. This closes a rate-limit bypass
where rotating a spoofed header let a caller choose a fresh bucket on every
request. Public sign-up is also disabled by default and requires an explicit
typed opt-in, using Better Auth's own disableSignUp gate so no email-existence
leak or second signup path is introduced.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 12:20:51 -05:00
co-authored by Claude Sonnet 5
parent 5e09330001
commit cafd513019
22 changed files with 1772 additions and 38 deletions
+52 -8
View File
@@ -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,
},