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
+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" };
},
};
}