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]>
67 lines
3.1 KiB
TypeScript
67 lines
3.1 KiB
TypeScript
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);
|
|
};
|
|
}
|