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:
co-authored by
Claude Sonnet 5
parent
5e09330001
commit
cafd513019
@@ -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" };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,3 +1,4 @@
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user