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
+19
View File
@@ -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: {
+1 -1
View File
@@ -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"]),
]);
+52 -1
View File
@@ -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,
]);
}
+280
View File
@@ -0,0 +1,280 @@
import net from "node:net";
export type ClientAddressMode = "direct" | "trusted-proxy";
export type ClientAddressConfigErrorCode =
| "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION"
| "CLIENT_ADDRESS_HEADER_INVALID"
| "CLIENT_ADDRESS_HEADER_MISSING"
| "CLIENT_ADDRESS_MODE_INVALID"
| "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY"
| "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID";
const clientAddressConfigErrorMessages: Readonly<Record<ClientAddressConfigErrorCode, string>> =
Object.freeze({
CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION:
"Direct client-address mode must not also configure a header or trusted proxies.",
CLIENT_ADDRESS_HEADER_INVALID: "The client-address header must be a valid HTTP header name.",
CLIENT_ADDRESS_HEADER_MISSING:
"Trusted-proxy client-address mode requires an explicit header name.",
CLIENT_ADDRESS_MODE_INVALID: 'The client-address mode must be "direct" or "trusted-proxy".',
CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY:
"Trusted-proxy client-address mode requires at least one trusted proxy address or range.",
CLIENT_ADDRESS_TRUSTED_PROXY_INVALID:
"Every trusted proxy entry must be a valid IPv4/IPv6 address or CIDR range.",
});
/** Never carries the offending mode, header name, or trusted-proxy value. */
export class ClientAddressConfigError extends Error {
readonly code: ClientAddressConfigErrorCode;
constructor(code: ClientAddressConfigErrorCode) {
super(clientAddressConfigErrorMessages[code]);
this.name = "ClientAddressConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface DirectClientAddressConfig {
readonly mode: "direct";
}
/**
* `header` is the one header name the reverse proxy is trusted to set — the platform transport
* boundary (`apps/api/src/client-address.ts`) overwrites any externally supplied copy of it before
* this policy ever sees the request, so it never needs to be `X-Forwarded-For` specifically, though
* that remains the common choice. `trustedProxies` lists the proxy's own address(es)/range(s), never
* a broad private range that also covers real clients.
*/
export interface TrustedProxyClientAddressConfig {
readonly mode: "trusted-proxy";
readonly header: string;
readonly trustedProxies: readonly string[];
}
export type ClientAddressConfig = DirectClientAddressConfig | TrustedProxyClientAddressConfig;
export interface ClientAddressConfigInput {
readonly mode?: unknown;
readonly header?: unknown;
readonly trustedProxies?: unknown;
}
const HEADER_TOKEN_PATTERN = /^[a-z0-9-]+$/u;
const CIDR_PREFIX_PATTERN = /^\d+$/u;
const IPV4_MAPPED_PATTERN = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/iu;
/**
* Normalizes a single address token for consistent identity/comparison: plain IPv4 is returned
* as-is, an IPv4-mapped IPv6 address (`::ffff:a.b.c.d` — the form Node's own `socket.remoteAddress`
* and real reverse proxies actually emit for an IPv4 peer on a dual-stack listener) collapses to its
* embedded IPv4 form, and any other valid IPv6 address is lowercased. A zone id (`%eth0`) is
* rejected as ambiguous outside its own interface. Anything that is not a syntactically valid IPv4
* or IPv6 address returns `undefined` rather than being guessed at.
*/
export function normalizeClientAddress(address: string): string | undefined {
const trimmed = address.trim();
if (trimmed.length === 0) {
return undefined;
}
if (net.isIPv4(trimmed)) {
return trimmed;
}
if (trimmed.includes("%")) {
return undefined;
}
if (net.isIPv6(trimmed)) {
const mapped = IPV4_MAPPED_PATTERN.exec(trimmed);
if (mapped) {
const embedded = mapped[1] as string;
return net.isIPv4(embedded) ? embedded : undefined;
}
return trimmed.toLowerCase();
}
return undefined;
}
interface ParsedTrustedProxyEntry {
readonly network: string;
readonly prefix: number;
readonly family: "ipv4" | "ipv6";
}
function parseTrustedProxyEntry(entry: string): ParsedTrustedProxyEntry | undefined {
const slashIndex = entry.indexOf("/");
const addressPart = slashIndex === -1 ? entry : entry.slice(0, slashIndex);
const network = normalizeClientAddress(addressPart);
if (!network) {
return undefined;
}
const family: "ipv4" | "ipv6" = net.isIPv4(network) ? "ipv4" : "ipv6";
const maxPrefix = family === "ipv4" ? 32 : 128;
if (slashIndex === -1) {
return { family, network, prefix: maxPrefix };
}
const prefixPart = entry.slice(slashIndex + 1);
if (!CIDR_PREFIX_PATTERN.test(prefixPart)) {
return undefined;
}
const prefix = Number(prefixPart);
return prefix <= maxPrefix ? { family, network, prefix } : undefined;
}
/** Trusted-proxy entries that are not a valid IPv4/IPv6 address or CIDR range — used both by config
* validation and, if ever needed diagnostically, by Doctor. */
export function findInvalidTrustedProxies(entries: readonly string[]): readonly string[] {
return entries.filter((entry) => parseTrustedProxyEntry(entry) === undefined);
}
/**
* Validates explicit, typed client-address configuration. The safe default (`mode: "direct"`) is
* used whenever `mode` is entirely omitted; every other field is validated strictly, and an
* incomplete or contradictory combination (proxy details without proxy mode, proxy mode without
* proxy details) is rejected outright rather than guessed at. `internalHeaderName` is the one
* platform-owned header (`TRUSTED_CLIENT_ADDRESS_HEADER`, `./runtime.js`) a caller must never be
* able to configure as its own "trusted" source header.
*/
export function createClientAddressConfig(
input: ClientAddressConfigInput = {},
internalHeaderName?: string,
): ClientAddressConfig {
const mode = input.mode ?? "direct";
if (mode !== "direct" && mode !== "trusted-proxy") {
throw new ClientAddressConfigError("CLIENT_ADDRESS_MODE_INVALID");
}
if (mode === "direct") {
if (input.header !== undefined || input.trustedProxies !== undefined) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION");
}
return Object.freeze({ mode: "direct" });
}
if (typeof input.header !== "string" || input.header.trim().length === 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_MISSING");
}
const header = input.header.trim().toLowerCase();
if (!HEADER_TOKEN_PATTERN.test(header)) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID");
}
if (internalHeaderName !== undefined && header === internalHeaderName) {
// The internal sanitized header is never a valid *source* header — trusting it as the
// caller-forwarded value would let a caller set its own "resolved" address directly.
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID");
}
if (!Array.isArray(input.trustedProxies) || input.trustedProxies.length === 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY");
}
const trustedProxies = input.trustedProxies.map((entry) => {
if (typeof entry !== "string" || entry.trim().length === 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
}
return entry.trim();
});
if (findInvalidTrustedProxies(trustedProxies).length > 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
}
return Object.freeze({
header,
mode: "trusted-proxy",
trustedProxies: Object.freeze(trustedProxies),
});
}
export type ClientAddressFailureReason =
| "FORWARDED_CHAIN_EXHAUSTED"
| "FORWARDED_HEADER_MALFORMED"
| "FORWARDED_HEADER_MISSING"
| "PEER_ADDRESS_UNAVAILABLE";
export type ClientAddressResolution =
| { readonly ok: true; readonly address: string }
| { readonly ok: false; readonly reason: ClientAddressFailureReason };
export interface ClientAddressResolver {
resolve(input: {
readonly peerAddress: string | undefined;
readonly headers: Headers;
}): ClientAddressResolution;
}
function buildTrustedProxyMatcher(trustedProxies: readonly string[]): (address: string) => boolean {
const blockList = new net.BlockList();
for (const entry of trustedProxies) {
const parsed = parseTrustedProxyEntry(entry);
if (!parsed) {
// Config validation already rejects this at startup; a matcher is only ever built from an
// already-validated config, so this can only mean the config was constructed unsafely.
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
}
blockList.addSubnet(parsed.network, parsed.prefix, parsed.family);
}
return (address: string): boolean =>
blockList.check(address, net.isIPv4(address) ? "ipv4" : "ipv6");
}
/**
* Builds the one server-owned client-address resolution policy this API runtime uses for every
* request, from already-validated configuration. Built once at startup (never per-request) so the
* trusted-proxy `BlockList` is compiled a single time. Depends only on `node:net` and the standard
* Fetch `Headers` type — never Hono or `@hono/node-server` — so it stays reusable by Core's own
* identity composition without pulling an HTTP framework into Core; the Node/Hono transport glue
* that reads the actual socket peer and rewrites the request lives in `apps/api` instead
* (`apps/api/src/client-address.ts`).
*
* Direct mode trusts only the actual immediate socket peer — never any request header. Trusted-proxy
* mode trusts the configured forwarded header's content only when the actual immediate socket peer
* matches a configured trusted-proxy address/range; otherwise it falls back to direct-mode behavior
* (the peer address itself), exactly as if no forwarded header existed at all. A forwarded chain is
* walked right to left: trailing hops that match a trusted proxy are skipped, and the first hop from
* the right that does not match is the resolved client address. A chain that is missing, empty,
* contains any malformed token, or consists entirely of trusted-proxy hops (no genuine client
* address left to resolve) fails closed rather than guessing — never a caller-chosen bucket, and
* never a silent collapse onto the proxy's own address.
*/
export function createClientAddressResolver(config: ClientAddressConfig): ClientAddressResolver {
const isTrustedProxy =
config.mode === "trusted-proxy" ? buildTrustedProxyMatcher(config.trustedProxies) : undefined;
return {
resolve({ peerAddress, headers }): ClientAddressResolution {
const normalizedPeer =
peerAddress === undefined ? undefined : normalizeClientAddress(peerAddress);
if (!normalizedPeer) {
return { ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" };
}
if (config.mode === "direct" || !isTrustedProxy?.(normalizedPeer)) {
return { ok: true, address: normalizedPeer };
}
const headerValue = headers.get(config.header);
if (headerValue === null) {
return { ok: false, reason: "FORWARDED_HEADER_MISSING" };
}
const tokens = headerValue.split(",").map((token) => token.trim());
if (tokens.length === 0 || tokens.some((token) => token.length === 0)) {
return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" };
}
const normalizedTokens: string[] = [];
for (const token of tokens) {
const normalized = normalizeClientAddress(token);
if (!normalized) {
return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" };
}
normalizedTokens.push(normalized);
}
for (let index = normalizedTokens.length - 1; index >= 0; index -= 1) {
const candidate = normalizedTokens[index];
if (candidate !== undefined && !isTrustedProxy(candidate)) {
return { ok: true, address: candidate };
}
}
return { ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" };
},
};
}
+34 -1
View File
@@ -5,6 +5,7 @@ export type IdentityConfigErrorCode =
| "BASE_URL_INVALID"
| "BASE_URL_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
View File
@@ -1,3 +1,4 @@
export * from "./client-address.js";
export * from "./config.js";
export * from "./database.js";
export * from "./runtime.js";
+34 -1
View File
@@ -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
+2
View File
@@ -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
+132
View File
@@ -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(
+350
View File
@@ -0,0 +1,350 @@
import { describe, expect, it } from "vitest";
import {
ClientAddressConfigError,
createClientAddressConfig,
createClientAddressResolver,
findInvalidTrustedProxies,
normalizeClientAddress,
} from "../src/identity/client-address.js";
const INTERNAL_HEADER = "x-ekklesia-client-address";
describe("createClientAddressConfig", () => {
it("defaults to direct mode when mode is entirely omitted", () => {
expect(createClientAddressConfig()).toEqual({ mode: "direct" });
expect(createClientAddressConfig({})).toEqual({ mode: "direct" });
expect(Object.isFrozen(createClientAddressConfig())).toBe(true);
});
it("rejects direct mode combined with a header or trusted proxies", () => {
expect(() => createClientAddressConfig({ header: "x-forwarded-for", mode: "direct" })).toThrow(
ClientAddressConfigError,
);
try {
createClientAddressConfig({ mode: "direct", trustedProxies: ["10.0.0.1"] });
} catch (error) {
expect(error).toMatchObject({ code: "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION" });
}
});
it("rejects an invalid mode value", () => {
let captured: unknown;
try {
createClientAddressConfig({ mode: "proxy" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_MODE_INVALID" });
});
it("accepts a valid trusted-proxy configuration", () => {
const config = createClientAddressConfig({
header: "X-Forwarded-For",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.5", "192.168.1.0/24"],
});
expect(config).toEqual({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.5", "192.168.1.0/24"],
});
expect(Object.isFrozen(config)).toBe(true);
if (config.mode === "trusted-proxy") {
expect(Object.isFrozen(config.trustedProxies)).toBe(true);
}
});
it("rejects trusted-proxy mode missing a header", () => {
let captured: unknown;
try {
createClientAddressConfig({ mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_MISSING" });
});
it("rejects an invalid header name", () => {
let captured: unknown;
try {
createClientAddressConfig({
header: "not a header!",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" });
});
it("rejects the internal platform header as a configured source header", () => {
let captured: unknown;
try {
createClientAddressConfig(
{
header: INTERNAL_HEADER,
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
},
INTERNAL_HEADER,
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" });
});
it("rejects trusted-proxy mode with an empty trusted-proxy list", () => {
let captured: unknown;
try {
createClientAddressConfig({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: [],
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY" });
});
it("rejects a malformed trusted-proxy entry", () => {
for (const trustedProxies of [
["not-an-ip"],
["10.0.0.1/33"],
["10.0.0.1/-1"],
["10.0.0.1/1e3"],
[""],
]) {
let captured: unknown;
try {
createClientAddressConfig({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies,
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID" });
}
});
it("never leaks the offending mode, header, or trusted-proxy value in the thrown error", () => {
const hostileHeader = "x-hostile-secret-header";
let captured: unknown;
try {
createClientAddressConfig({
header: hostileHeader,
mode: "trusted-proxy",
trustedProxies: ["definitely-not-an-ip"],
});
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain(hostileHeader);
expect(publicText).not.toContain("definitely-not-an-ip");
});
});
describe("findInvalidTrustedProxies", () => {
it("identifies exactly the malformed entries", () => {
expect(
findInvalidTrustedProxies(["10.0.0.1", "not-an-ip", "192.168.0.0/24", "10.0.0.1/99"]),
).toEqual(["not-an-ip", "10.0.0.1/99"]);
});
it("accepts IPv6 addresses and ranges", () => {
expect(findInvalidTrustedProxies(["::1", "fd00::/8", "2001:db8::1"])).toEqual([]);
});
});
describe("normalizeClientAddress", () => {
it("passes a plain IPv4 address through unchanged", () => {
expect(normalizeClientAddress("192.0.2.10")).toBe("192.0.2.10");
});
it("lowercases a plain IPv6 address", () => {
expect(normalizeClientAddress("2001:DB8::1")).toBe("2001:db8::1");
});
it("collapses an IPv4-mapped IPv6 address to its embedded IPv4 form", () => {
expect(normalizeClientAddress("::ffff:127.0.0.1")).toBe("127.0.0.1");
expect(normalizeClientAddress("::FFFF:203.0.113.5")).toBe("203.0.113.5");
});
it("rejects a zone id as ambiguous", () => {
expect(normalizeClientAddress("fe80::1%eth0")).toBeUndefined();
});
it("rejects malformed, empty, or non-IP input", () => {
for (const value of ["", " ", "not-an-ip", "999.999.999.999", "1.2.3"]) {
expect(normalizeClientAddress(value)).toBeUndefined();
}
});
it("trims surrounding whitespace", () => {
expect(normalizeClientAddress(" 192.0.2.10 ")).toBe("192.0.2.10");
});
});
describe("createClientAddressResolver — direct mode", () => {
it("resolves the actual immediate socket peer, ignoring any forwarded header", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.99" }),
peerAddress: "192.0.2.1",
});
expect(result).toEqual({ address: "192.0.2.1", ok: true });
});
it("fails closed when no peer address is available", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({ headers: new Headers(), peerAddress: undefined });
expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" });
});
it("fails closed when the peer address is not a valid IP", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({ headers: new Headers(), peerAddress: "not-an-ip" });
expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" });
});
it("normalizes an IPv4-mapped IPv6 peer to its IPv4 form", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({ headers: new Headers(), peerAddress: "::ffff:198.51.100.7" });
expect(result).toEqual({ address: "198.51.100.7", ok: true });
});
it("gives distinct real peer addresses distinct resolved identities", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const first = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.2" });
const second = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.3" });
expect(first).toEqual({ address: "127.0.0.2", ok: true });
expect(second).toEqual({ address: "127.0.0.3", ok: true });
expect(first).not.toEqual(second);
});
});
describe("createClientAddressResolver — trusted-proxy mode", () => {
function proxyResolver(trustedProxies: readonly string[] = ["10.0.0.1", "10.0.0.2"]) {
return createClientAddressResolver({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies,
});
}
it("ignores the forwarded header entirely when the immediate peer is not a trusted proxy", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.99" }),
peerAddress: "198.51.100.1",
});
// Falls back to direct-mode behavior: the untrusted peer itself, never the header content.
expect(result).toEqual({ address: "198.51.100.1", ok: true });
});
it("honors a single-value forwarded header from a trusted proxy", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("walks a chained forwarded header right-to-left, skipping trusted-proxy hops", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.2" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("walks past multiple trusted-proxy hops to find the genuine client", () => {
const resolver = proxyResolver(["10.0.0.1", "10.0.0.2", "10.0.0.3"]);
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.3, 10.0.0.2" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("fails closed when the trusted proxy sends no forwarded header at all", () => {
const resolver = proxyResolver();
const result = resolver.resolve({ headers: new Headers(), peerAddress: "10.0.0.1" });
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MISSING" });
});
it("fails closed on an empty (but present) forwarded header value", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
});
it("fails closed on a malformed token anywhere in the chain", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, not-an-ip" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
});
it("fails closed on an empty comma-separated segment (ambiguous multi-valued form)", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5,, 10.0.0.1" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
});
it("fails closed when the entire chain resolves to trusted-proxy hops with no genuine client left", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "10.0.0.2, 10.0.0.1" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" });
});
it("normalizes an IPv4-mapped IPv6 hop before matching it against a trusted IPv4 proxy", () => {
const resolver = proxyResolver(["10.0.0.1"]);
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, ::ffff:10.0.0.1" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("matches a trusted-proxy CIDR range, not only an exact address", () => {
const resolver = proxyResolver(["192.168.1.0/24"]);
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5" }),
peerAddress: "192.168.1.42",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("never lets a spoofed header from an untrusted peer choose a client identity that collides with a trusted flow", () => {
const resolver = proxyResolver();
// An attacker connecting directly (not through the real proxy) claims to forward from a
// trusted-proxy address itself — since the *immediate peer* is what gates trust, not header
// content, this must still resolve to the attacker's own real peer address.
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }),
peerAddress: "203.0.113.200",
});
expect(result).toEqual({ address: "203.0.113.200", ok: true });
});
});
+36
View File
@@ -15,6 +15,7 @@ describe("Core identity configuration", () => {
expect(config).toEqual({
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;
+34 -4
View File
@@ -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", () => {