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
@@ -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