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
+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);
};
}