fix: secure authentication request identity

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-26 12:20:51 -05:00
co-authored by Claude Sonnet 5
parent 5e09330001
commit cafd513019
22 changed files with 1772 additions and 38 deletions
+12 -1
View File
@@ -119,7 +119,18 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
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);
}
+66
View File
@@ -0,0 +1,66 @@
import { type ClientAddressResolver, TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
type NodeServerBindings = HttpBindings | Http2Bindings;
export {
type ClientAddressConfig,
ClientAddressConfigError,
type ClientAddressConfigErrorCode,
type ClientAddressConfigInput,
type ClientAddressFailureReason,
type ClientAddressMode,
type ClientAddressResolution,
type ClientAddressResolver,
createClientAddressConfig,
createClientAddressResolver,
type DirectClientAddressConfig,
findInvalidTrustedProxies,
normalizeClientAddress,
type TrustedProxyClientAddressConfig,
} from "@ekklesia/core/identity";
/** A fixed, generic diagnostic — never the peer address, forwarded header content, request path, or
* any other request detail — logged only when client-address resolution fails closed. */
export const CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE =
"[client-address] A request's caller address could not be resolved safely; rejecting with 503.";
function sanitizedClientAddressUnresolvedResponse(): Response {
return new Response(JSON.stringify({ code: "CLIENT_ADDRESS_UNRESOLVED" }), {
headers: { "cache-control": "no-store", "content-type": "application/json" },
status: 503,
});
}
/**
* Wraps a `@hono/node-server` fetch callback at the exact Node/API transport boundary — the one
* place in this repository below `apps/api` that reads the raw Node `IncomingMessage` — with
* Core's framework-agnostic client-address resolution policy (`@ekklesia/core/identity`). Resolves
* the caller's trusted address from the real socket peer (`env.incoming.socket.remoteAddress`)
* before the wrapped callback — and therefore Better Auth's own rate limiter — ever sees the
* request, then overwrites (never merely appends) any externally supplied copy of
* `TRUSTED_CLIENT_ADDRESS_HEADER` with that resolved value. A caller can therefore never choose its
* own resolved address, whether by spoofing a forwarded header or by sending the internal header
* directly. Resolution failure never reaches the wrapped callback at all — no Better Auth logic, no
* Core mutation, and no module handler ever runs for a request whose caller address could not be
* resolved safely.
*/
export function wrapFetchWithClientAddress(
fetchCallback: (request: Request, env: NodeServerBindings) => Response | Promise<Response>,
resolver: ClientAddressResolver,
): (request: Request, env: NodeServerBindings) => Promise<Response> {
return async (request: Request, env: NodeServerBindings): Promise<Response> => {
const resolution = resolver.resolve({
headers: request.headers,
peerAddress: env.incoming.socket.remoteAddress,
});
if (!resolution.ok) {
console.error(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
return sanitizedClientAddressUnresolvedResponse();
}
const headers = new Headers(request.headers);
headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, resolution.address);
const sanitizedRequest = new Request(request, { headers });
return fetchCallback(sanitizedRequest, env);
};
}
+11 -1
View File
@@ -4,6 +4,11 @@ import {
type IdentityConfig,
type 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
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,
},
+405 -4
View File
@@ -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);
});
});
+19
View File
@@ -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([]),
+113
View File
@@ -0,0 +1,113 @@
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import {
CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE,
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "../src/client-address.js";
function fakeEnv(remoteAddress: string | undefined) {
return {
incoming: { socket: { remoteAddress } },
outgoing: {},
} as unknown as Parameters<typeof wrapFetchWithClientAddress>[0] extends (
request: Request,
env: infer Env,
) => unknown
? Env
: never;
}
describe("wrapFetchWithClientAddress", () => {
it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-in/email");
await wrapped(request, fakeEnv("203.0.113.9"));
expect(wrappedFetch).toHaveBeenCalledTimes(1);
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9");
});
it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-in/email", {
headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" },
});
await wrapped(request, fakeEnv("192.0.2.1"));
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1");
// A single value, never a comma-joined "caller-value, resolved-value" — set(), not append().
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(",");
});
it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const response = await wrapped(
new Request("https://example.test/api/auth/sign-in/email"),
fakeEnv(undefined),
);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(wrappedFetch).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
} finally {
errorSpy.mockRestore();
}
});
it("never logs the peer address, headers, or request path on a resolution failure", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
await wrapped(
new Request("https://example.test/api/auth/sign-in/email", {
headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" },
}),
fakeEnv("not-a-valid-ip"),
);
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
expect(emitted).not.toContain("super-secret-session");
expect(emitted).not.toContain("203.0.113.1");
expect(emitted).not.toContain("sign-in");
expect(emitted).not.toContain("not-a-valid-ip");
} finally {
errorSpy.mockRestore();
}
});
it("preserves the request method, body, and other headers unrelated to the internal address header", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-up/email", {
body: JSON.stringify({ email: "[email protected]" }),
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
method: "POST",
});
await wrapped(request, fakeEnv("192.0.2.1"));
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.method).toBe("POST");
expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test");
expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "[email protected]" }));
});
});
+30
View File
@@ -21,10 +21,12 @@ describe("API runtime configuration", () => {
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
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],
},