e62b46945a
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>
171 lines
7.5 KiB
TypeScript
171 lines
7.5 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import * as dotenv from 'dotenv';
|
|
import logger from '../../middleware/logger.js';
|
|
|
|
dotenv.config();
|
|
|
|
/**
|
|
* One place that reads the admin module's environment. Both admin.auth.ts
|
|
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
|
|
* same origin list, and a second parser would drift from this one.
|
|
*
|
|
* Read this before changing the environment handling below: several security
|
|
* properties depend on it, and they are deliberately arranged to fail *safe*.
|
|
*
|
|
* `NODE_ENV` is opt-in to relaxed behaviour, not opt-in to strict behaviour.
|
|
* Only the explicit values 'development' and 'test' relax anything; anything
|
|
* else - including NODE_ENV being unset, which is exactly what a fresh Plesk
|
|
* vhost gives you - is treated as production. The inverse arrangement is a
|
|
* trap: it degrades the cookie domain, the CORS origin list and the signing
|
|
* key all at once, and every one of those failures is silent.
|
|
*
|
|
* The signing key is never allowed to be a known constant. In dev, an unset
|
|
* BETTER_AUTH_SECRET becomes a random per-process value: sessions do not
|
|
* survive a restart, which is mildly annoying and much better than a default
|
|
* secret that can be copied out of this file and used against production.
|
|
*/
|
|
|
|
const nodeEnv = process.env.NODE_ENV;
|
|
|
|
// Explicitly relaxed environments. Everything else, unset included, is strict.
|
|
const isRelaxedEnv = nodeEnv === 'development' || nodeEnv === 'test';
|
|
|
|
export const isProd = !isRelaxedEnv;
|
|
|
|
const required = (name: string, devDefault: string): string => {
|
|
const value = process.env[name];
|
|
if (value) {
|
|
return value;
|
|
}
|
|
if (isProd) {
|
|
logger.error(
|
|
`Admin module: ${name} is not set (NODE_ENV=${nodeEnv ?? 'unset'}, so strict mode applies; ` +
|
|
'set NODE_ENV=development for local work)'
|
|
);
|
|
throw new Error(`${name} must be set unless NODE_ENV is development or test`);
|
|
}
|
|
return devDefault;
|
|
};
|
|
|
|
export const API_BASE_URL = required('API_BASE_URL', 'http://localhost:3000');
|
|
export const ADMIN_APP_URL = required('ADMIN_APP_URL', 'http://localhost:3002');
|
|
|
|
// 32+ random bytes; better-auth signs cookies and reset tokens with it.
|
|
// Rotating it invalidates every session, which is why it is not derived.
|
|
// There is no hardcoded fallback on purpose: a constant committed here would
|
|
// be a published signing key the moment someone deploys without setting it.
|
|
export const BETTER_AUTH_SECRET = required(
|
|
'BETTER_AUTH_SECRET',
|
|
crypto.randomBytes(48).toString('base64')
|
|
);
|
|
|
|
// Passkeys are bound to this: a credential registered for "nachklang.art"
|
|
// works on every *.nachklang.art host, one registered for "localhost" only
|
|
// works in dev. Changing it invalidates every registered passkey.
|
|
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
|
|
|
|
const parseList = (value: string | undefined, fallback: string[]): string[] => {
|
|
const parsed = (value || '')
|
|
.split(',')
|
|
.map(entry => entry.trim())
|
|
.filter(entry => entry.length > 0);
|
|
|
|
return parsed.length > 0 ? parsed : fallback;
|
|
};
|
|
|
|
// The apps whose frontends may talk to /admin/* with credentials.
|
|
export const APP_ORIGINS = parseList(process.env.APP_ORIGINS, []).map(origin => origin.replace(/\/$/, ''));
|
|
|
|
// Kept in sync by construction rather than by three separate lists: the admin
|
|
// app itself always counts, and dev adds the local ports.
|
|
export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
|
|
ADMIN_APP_URL.replace(/\/$/, ''),
|
|
...APP_ORIGINS
|
|
]));
|
|
|
|
/**
|
|
* The header the reverse proxy puts the real client IP in, and the proxy hops
|
|
* to trust when reading it.
|
|
*
|
|
* This matters more than it looks. better-auth does not know about Express's
|
|
* `trust proxy`; it reads the request itself. If it cannot resolve a client IP
|
|
* it falls back to a single shared bucket ("no-trusted-ip") for the whole
|
|
* process - and /sign-in/* carries a default of 3 requests per 10 seconds, so
|
|
* one noisy client would lock every user out of every app.
|
|
*
|
|
* Without TRUSTED_PROXY_IPS, better-auth rejects a multi-value
|
|
* x-forwarded-for outright (it cannot tell which hop is the client), which is
|
|
* exactly the case that produces that shared bucket. Set it to the address or
|
|
* CIDR of Plesk's nginx. Conversely, listing a header the proxy does not
|
|
* overwrite lets a client set its own IP and mint itself an unlimited
|
|
* brute-force budget - so the default is the single header nginx sets, not a
|
|
* permissive list.
|
|
*/
|
|
|
|
/**
|
|
* `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 && 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 single-value header needs no ' +
|
|
'trusted proxies, so this warning is expected on a plain single-proxy setup.'
|
|
);
|
|
}
|
|
|
|
export const ADMIN_BOOTSTRAP_EMAIL = process.env.ADMIN_BOOTSTRAP_EMAIL || '';
|
|
|
|
/**
|
|
* Whether to write invitation links to the log. An invitation link is a live
|
|
* account-creation credential, so this is an explicit opt-in rather than
|
|
* something inferred from NODE_ENV: local work needs it (the mail relay is
|
|
* usually off, and only the token's hash is stored, so there is otherwise no
|
|
* way to walk the accept flow), and production must never have it.
|
|
*
|
|
* Refused outright in strict mode, so setting it in a production .env by
|
|
* accident fails at boot instead of quietly filling the log with credentials.
|
|
*/
|
|
export const LOG_INVITE_LINKS = process.env.ADMIN_LOG_INVITE_LINKS === 'true' && !isProd;
|
|
|
|
if (process.env.ADMIN_LOG_INVITE_LINKS === 'true' && isProd) {
|
|
logger.error('Admin module: ADMIN_LOG_INVITE_LINKS is set outside development - refusing to log invitation tokens');
|
|
}
|