Add a CLIENT_IP_HEADERS escape hatch and stop the auth handler hanging
Two changes, both from working out how to verify the client-IP configuration on the Plesk vhost before deploying. CLIENT_IP_HEADERS=none now trusts no header at all. This covers the one case where the wrong setting is worse than no setting: if the proxy does not overwrite the header we trust, any client can send it and mint itself an unlimited brute-force budget against /sign-in. Falling back to the shared rate-limit bucket is a nuisance - one noisy client can lock everyone out for ten seconds at a time - but it fails closed, and it can be reverted from the environment without a deploy. An empty or unset value still means "use the default": a stray blank line in a .env must not silently change how requests are bucketed, so only the explicit word does that. An empty array is what better-auth reads as "no headers"; it falls back to its own default only when the option is absent, and [] is truthy. The TRUSTED_PROXY_IPS boot warning was overstating the risk. It now says that a single-value header needs no trusted proxies, so seeing it on a plain single-proxy setup is expected rather than a problem to chase. Separately: a rejected promise from better-auth's handler used to escape as an unhandled rejection, leaving the request hanging forever with no response while the process logged an uncaughtException. Express 4 does not await an async handler, and nearly every better-auth route touches the admin database, so any database blip would have done this. Found by pointing ADMIN_DB at a database the user cannot open while testing the hatch. The handler now answers 503, so the caller learns and the other domains keep serving; verified as a 24ms response instead of a hang, with / and /feedback/admin/me unaffected. test/admin/admin.config.test.ts is new: it pins both branches of the hatch and the rule that an unset NODE_ENV counts as production. It stubs dotenv, because admin.config would otherwise read the repo's own .env and quietly reintroduce NODE_ENV=development - the exact value several of those cases exist to remove. 157 unit tests and 41 integration tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,16 +101,53 @@ export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
|
||||
* brute-force budget - so the default is the single header nginx sets, not a
|
||||
* permissive list.
|
||||
*/
|
||||
export const CLIENT_IP_HEADERS = parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
|
||||
|
||||
/**
|
||||
* `CLIENT_IP_HEADERS=none` trusts no header at all.
|
||||
*
|
||||
* This is the escape hatch for the one case where the wrong setting is worse
|
||||
* than no setting: if the proxy turns out NOT to overwrite the header we are
|
||||
* trusting, any client can send it and mint itself an unlimited brute-force
|
||||
* budget against /sign-in. Falling back to the shared bucket is bad (one noisy
|
||||
* client can lock the organisation out for ten seconds at a time) but it is
|
||||
* bad in a way that fails closed, and it can be reverted from the environment
|
||||
* without a deploy.
|
||||
*
|
||||
* Reach for it only after a check has actually failed - `SELECT ipAddress FROM
|
||||
* session ORDER BY createdAt DESC` showing 127.0.0.1 or NULL for a real remote
|
||||
* sign-in - and take it back out once the header is configured.
|
||||
*
|
||||
* An empty or unset value still means "use the default", not "trust nothing":
|
||||
* a stray blank line in a .env must not silently change how requests are
|
||||
* bucketed. Only the explicit word does that.
|
||||
*/
|
||||
const TRUST_NO_HEADER = 'none';
|
||||
|
||||
export const TRUST_NO_CLIENT_IP_HEADER =
|
||||
(process.env.CLIENT_IP_HEADERS || '').trim().toLowerCase() === TRUST_NO_HEADER;
|
||||
|
||||
// An empty array is what better-auth reads as "no headers": it only falls back
|
||||
// to its own default when the option is absent, and `[]` is truthy.
|
||||
export const CLIENT_IP_HEADERS = TRUST_NO_CLIENT_IP_HEADER
|
||||
? []
|
||||
: parseList(process.env.CLIENT_IP_HEADERS, ['x-real-ip']);
|
||||
|
||||
export const TRUSTED_PROXY_IPS = parseList(process.env.TRUSTED_PROXY_IPS, []);
|
||||
|
||||
if (isProd && TRUSTED_PROXY_IPS.length === 0) {
|
||||
if (isProd && TRUST_NO_CLIENT_IP_HEADER) {
|
||||
logger.warn(
|
||||
'Admin module: CLIENT_IP_HEADERS=none - no client-IP header is trusted, so every ' +
|
||||
'request shares one rate-limit bucket and /sign-in allows 3 attempts per 10 seconds ' +
|
||||
'for everyone combined. This is the safe fallback, not a destination: configure the ' +
|
||||
'header the proxy actually sets and remove it.'
|
||||
);
|
||||
} else if (isProd && TRUSTED_PROXY_IPS.length === 0) {
|
||||
logger.warn(
|
||||
'Admin module: TRUSTED_PROXY_IPS is not set. If the proxy sends a multi-value ' +
|
||||
`${CLIENT_IP_HEADERS.join('/')}, better-auth cannot resolve a client IP and every ` +
|
||||
'request shares one rate-limit bucket. Verify with: SELECT `key` FROM rateLimit - ' +
|
||||
'a "no-trusted-ip" row means this is happening.'
|
||||
'a "no-trusted-ip" row means this is happening. A single-value header needs no ' +
|
||||
'trusted proxies, so this warning is expected on a plain single-proxy setup.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user