Harden the admin module after a fresh-context review
Six defects found by an independent review of 7aac07a.
Environment handling now fails safe. NODE_ENV=production was gating the
signing key, the cookie domain, the CORS origin list and invitation-token
logging all at once, and it was documented nowhere - an unset value, which is
what a fresh Plesk vhost gives you, silently degraded all four. Only
'development' and 'test' relax anything now; everything else, unset included,
is strict. The hardcoded fallback secret is gone (dev gets a random
per-process one, so no committed value can ever sign a production cookie),
and invitation-link logging is an explicit ADMIN_LOG_INVITE_LINKS opt-in that
is refused in strict mode.
Rate limiting no longer collapses into a single global bucket. Without
trustedProxies, better-auth rejects a multi-value x-forwarded-for, resolves no
client IP, and keys every request to "no-trusted-ip" - where /sign-in/*
allows 3 requests per 10 seconds, so one noisy client could lock the whole
organisation out. CLIENT_IP_HEADERS and TRUSTED_PROXY_IPS make this explicit,
the unspecified x-forwarded-for fallback is gone, and strict mode warns at
boot when no trusted proxy is configured.
Invite acceptance is transactional. The user and its credential account go in
one runWithTransaction, as better-auth's own sign-up route does. A transaction
cannot span the permission and invitation writes - those use this module's own
pool - so a failure there is compensated: the user row is deleted and the
invitation un-marked, so the link works again instead of leaving the invitee
with a burnt token and an account no route can repair.
The last-admin guards were check-then-act. Two admins each removing the
other's admin permission could both pass the check and both commit, leaving
nobody able to administer anything. The count now runs inside the write
transaction under SELECT ... FOR UPDATE.
Also: lastSignInAt filtered expired sessions in the detail endpoint but not
the list, so the two disagreed; and the integration suite never reset
rateLimit, leaving it one added sign-in away from 429s that look like auth
bugs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as dotenv from 'dotenv';
|
||||
import logger from '../../middleware/logger.js';
|
||||
|
||||
@@ -8,13 +9,28 @@ dotenv.config();
|
||||
* (better-auth trustedOrigins, passkey origins) and app.ts (CORS) need the
|
||||
* same origin list, and a second parser would drift from this one.
|
||||
*
|
||||
* In production every value is mandatory: a missing BETTER_AUTH_SECRET or a
|
||||
* wrong ADMIN_APP_URL is the kind of misconfiguration that fails as "login
|
||||
* silently does nothing" hours later, so it fails at boot instead. In dev the
|
||||
* localhost defaults below let a fresh checkout run without an .env.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const isProd = process.env.NODE_ENV === '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];
|
||||
@@ -22,8 +38,11 @@ const required = (name: string, devDefault: string): string => {
|
||||
return value;
|
||||
}
|
||||
if (isProd) {
|
||||
logger.error(`Admin module: ${name} is not set`);
|
||||
throw new Error(`${name} must be set in production`);
|
||||
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;
|
||||
};
|
||||
@@ -33,9 +52,11 @@ 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',
|
||||
'dev-only-insecure-secret-do-not-use-in-production'
|
||||
crypto.randomBytes(48).toString('base64')
|
||||
);
|
||||
|
||||
// Passkeys are bound to this: a credential registered for "nachklang.art"
|
||||
@@ -43,15 +64,17 @@ export const BETTER_AUTH_SECRET = required(
|
||||
// works in dev. Changing it invalidates every registered passkey.
|
||||
export const PASSKEY_RP_ID = process.env.PASSKEY_RP_ID || (isProd ? 'nachklang.art' : 'localhost');
|
||||
|
||||
// The apps whose frontends may talk to /admin/* with credentials.
|
||||
const parseOrigins = (value: string | undefined): string[] => {
|
||||
return (value || '')
|
||||
const parseList = (value: string | undefined, fallback: string[]): string[] => {
|
||||
const parsed = (value || '')
|
||||
.split(',')
|
||||
.map(origin => origin.trim().replace(/\/$/, ''))
|
||||
.filter(origin => origin.length > 0);
|
||||
.map(entry => entry.trim())
|
||||
.filter(entry => entry.length > 0);
|
||||
|
||||
return parsed.length > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
export const APP_ORIGINS = parseOrigins(process.env.APP_ORIGINS);
|
||||
// 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.
|
||||
@@ -60,4 +83,51 @@ export const ADMIN_ALLOWED_ORIGINS = Array.from(new Set([
|
||||
...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.
|
||||
*/
|
||||
export const CLIENT_IP_HEADERS = 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) {
|
||||
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.'
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user