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
@@ -7,11 +7,17 @@ import {
|
||||
} from "@ekklesia/contracts";
|
||||
import { getTableName } from "drizzle-orm";
|
||||
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
|
||||
import {
|
||||
type ClientAddressConfigInput,
|
||||
ClientAddressConfigError,
|
||||
createClientAddressConfig,
|
||||
} from "../identity/client-address.js";
|
||||
import {
|
||||
type CreateIdentityConfigOptions,
|
||||
createIdentityConfig,
|
||||
IdentityConfigError,
|
||||
} from "../identity/config.js";
|
||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
|
||||
import { identityAdapterSchema } from "../identity/schema.js";
|
||||
import {
|
||||
createPostgresMigrationDatabase,
|
||||
@@ -44,6 +50,7 @@ export type DoctorCheckId =
|
||||
| "postgresql.connection"
|
||||
| "postgresql.plan"
|
||||
| "identity.configuration"
|
||||
| "identity.client-address"
|
||||
| "object-storage.integration";
|
||||
|
||||
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
|
||||
@@ -64,11 +71,13 @@ export interface DoctorReport {
|
||||
export interface DoctorIdentityConfigurationOptions {
|
||||
readonly baseUrl?: string | undefined;
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
readonly publicSignupEnabled?: string | undefined;
|
||||
readonly secret?: string | undefined;
|
||||
readonly trustedOrigins?: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export interface DoctorOptions {
|
||||
readonly clientAddress?: ClientAddressConfigInput | undefined;
|
||||
readonly databaseUrl?: string | undefined;
|
||||
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
|
||||
readonly moduleDiscovery: ModuleDiscoveryOptions;
|
||||
@@ -363,6 +372,7 @@ function identityConfigurationCheck(
|
||||
const hasAnyValue =
|
||||
input?.secret !== undefined ||
|
||||
input?.baseUrl !== undefined ||
|
||||
input?.publicSignupEnabled !== undefined ||
|
||||
(input?.trustedOrigins?.length ?? 0) > 0;
|
||||
|
||||
if (!hasAnyValue) {
|
||||
@@ -373,9 +383,10 @@ function identityConfigurationCheck(
|
||||
}
|
||||
|
||||
try {
|
||||
createIdentityConfig(
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
baseUrl: input?.baseUrl,
|
||||
publicSignupEnabled: input?.publicSignupEnabled,
|
||||
secret: input?.secret,
|
||||
trustedOrigins: input?.trustedOrigins,
|
||||
},
|
||||
@@ -383,6 +394,9 @@ function identityConfigurationCheck(
|
||||
);
|
||||
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
|
||||
schemaDetail,
|
||||
config.publicSignupEnabled
|
||||
? "Public signup is explicitly ENABLED."
|
||||
: "Public signup is disabled (safe default).",
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
@@ -394,6 +408,41 @@ function identityConfigurationCheck(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure configuration validation only, mirroring `identityConfigurationCheck`: never resolves a real
|
||||
* peer address or reads a real request. Omitted configuration is a `pass`, not a skip — direct mode
|
||||
* (trusting only the actual immediate socket peer) is itself a complete, safe default, unlike
|
||||
* identity configuration, which has no meaningful default at all. An explicitly configured
|
||||
* trusted-proxy mode reports its header name and trusted-proxy count; an invalid or contradictory
|
||||
* combination is a `failure`, distinguishing the three states this pass's audit specifically calls
|
||||
* for: safe direct mode, explicitly configured proxy mode, and an invalid configuration.
|
||||
*/
|
||||
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
|
||||
try {
|
||||
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
|
||||
if (config.mode === "direct") {
|
||||
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
|
||||
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
|
||||
]);
|
||||
}
|
||||
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
|
||||
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
|
||||
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
|
||||
]);
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof ClientAddressConfigError
|
||||
? error.code
|
||||
: "CLIENT_ADDRESS_CONFIGURATION_INVALID";
|
||||
return freezeCheck(
|
||||
"identity.client-address",
|
||||
"failure",
|
||||
"Client-address configuration invalid",
|
||||
[`[${code}] Configured client-address values failed validation.`],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
|
||||
return [
|
||||
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
|
||||
@@ -548,6 +597,7 @@ export async function diagnoseEkklesia(
|
||||
}
|
||||
|
||||
const identityCheck = identityConfigurationCheck(options.identityConfiguration);
|
||||
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
|
||||
|
||||
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
|
||||
"Core storage integration has not been implemented yet.",
|
||||
@@ -563,6 +613,7 @@ export async function diagnoseEkklesia(
|
||||
postgresConnectionCheck,
|
||||
postgresPlanCheck,
|
||||
identityCheck,
|
||||
clientAddressDoctorCheck,
|
||||
storageCheck,
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user