Merge pull request #684: fix(auth): widen the per-IP login throttle to 50, keep the per-account lockout at 10

This commit is contained in:
Codeman maintainer committed 2026-10-24 15:16:07 +02:00
commit 053615f9ae
15 files changed
+489 -73

No files matched your search

+17 -1
View File
@@ -21,9 +21,25 @@ export const MAX_AUTH_SESSIONS = 100;
// Rate Limiting
// ============================================================================
/** Max failed auth attempts per IP before 429 rejection */
/**
* Max failed auth attempts per bucket before 429 rejection. Applies to the
* single-user per-IP bucket, the hook-secret bucket and two multi-user buckets:
* the (username, IP) gate, checked before the scrypt verify, and the
* per-username bucket, checked after it.
*/
export const AUTH_FAILURE_MAX = 10;
/**
* Max failed login attempts per client IP for the multi-user pre-gate, which
* runs before the scrypt verify and so refuses even a correct password once
* reached. It bounds scrypt cost from one source and is wider than
* AUTH_FAILURE_MAX because one egress IP (NAT, a tunnel) can carry many users'
* failures. Verified guesses against any one account are capped by the
* (username, IP) gate at AUTH_FAILURE_MAX, not by this. Single-user mode does
* not use this.
*/
export const AUTH_IP_FAILURE_MAX = 50;
/** Failed auth attempt tracking window (ms) */
export const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
+172 -50
View File
@@ -9,7 +9,7 @@
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import type { AuthSessionRecord } from '../ports/auth-port.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
@@ -17,6 +17,7 @@ import {
AUTH_SESSION_TTL_MS,
MAX_AUTH_SESSIONS,
AUTH_FAILURE_MAX,
AUTH_IP_FAILURE_MAX,
AUTH_FAILURE_WINDOW_MS,
} from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
@@ -32,7 +33,7 @@ import {
lostWebviewFramePage,
LOST_FRAME_PAGE_CSP,
} from '../webview-proxy.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
import { ApiErrorCode, createErrorResponse, type AuthUser, type UserRecord } from '../../types.js';
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
// ownership helpers default to a synthetic admin (see route-helpers).
@@ -53,6 +54,8 @@ interface AuthState {
hookSecretFailures: StaleExpirationMap<string, number> | null;
/** Per-username Basic-auth failure bucket (multi-user only). */
userFailures: StaleExpirationMap<string, number> | null;
/** Per-(username, client IP) Basic-auth failure bucket (multi-user only). */
userIpFailures: StaleExpirationMap<string, number> | null;
}
/** Rate-limit response for a client that exceeded the failure cap. */
@@ -76,6 +79,22 @@ function parseBasicAuth(header?: string): { username: string; password: string }
}
}
/**
* Key of the multi-user in-flight verify map: a SHA-256 digest of (client IP,
* normalized username, password), so the plaintext password is never held as a key.
* The JSON array keeps the three parts unambiguous for any strings (a NUL-joined
* string is not, since a username may carry a NUL). Never logged or persisted, and
* gone from the map once its verify settles.
*/
function verifyFlightKey(clientIp: string, normUser: string, password: string): string {
return createHash('sha256')
.update(JSON.stringify([clientIp, normUser, password]))
.digest('hex');
}
/** What one Basic verify decided, shared by the request that ran it and every joiner. */
type BasicVerifyOutcome = { user: UserRecord } | { user: null; limited: boolean };
/**
* The `/api/hook-event` + `/api/status-telemetry` localhost bypass, shared by the
* single-user and multi-user auth hooks so the security-critical logic has ONE
@@ -303,6 +322,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
qrAuthFailures: null,
hookSecretFailures: null,
userFailures: null,
userIpFailures: null,
};
// Always declare req.authUser so downstream reads are safe (single-user leaves it
@@ -350,13 +370,33 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
const hookSecretFailures = state.hookSecretFailures;
if (multiUser) {
// Per-username failure bucket: a botnet can't brute-force one account across
// many IPs, and one user behind a NAT can't lock out everyone else.
// Per-username failure bucket: failed guesses against one account answer 429
// from any IP once it fills, and one user behind a NAT can't lock out everyone
// else. Checked after the verify, so it never denies a correct password.
state.userFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
// Per-(username, IP) failure bucket: checked BEFORE the verify, so it is what
// caps verified guesses against one account from one source. Keyed on the pair,
// so other IPs can't lock the account out and two users behind one shared IP
// don't lock each other out. Same decay as the other buckets: it holds one key
// per (IP, username) pair whose verify failed or is still in flight, swept by
// the same TTL, so like them it grows only with scrypt verifies.
state.userIpFailures = new StaleExpirationMap<string, number>({
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(
app,
https,
authSessions,
authFailures,
hookSecretFailures,
state.userFailures,
state.userIpFailures,
basePath
);
return state;
}
@@ -465,8 +505,9 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean, bas
/**
* Multi-user auth hook (async, because password verification runs scrypt). Verifies
* `username:password` against the user store, mints an identity-carrying cookie,
* decorates `req.authUser`, enforces the per-IP + per-username rate limits, and the
* `mustChangePassword` lockbox. The single-user hook above is left untouched.
* decorates `req.authUser`, enforces the per-IP, per-(username, IP) and per-username
* rate limits, and the `mustChangePassword` lockbox. Identical logins in flight at once
* share one verify. The single-user hook above is left untouched.
*/
function registerMultiUserAuthHook(
app: FastifyInstance,
@@ -475,6 +516,7 @@ function registerMultiUserAuthHook(
authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number>,
userIpFailures: StaleExpirationMap<string, number>,
basePath = ''
): void {
const setSessionCookie = (reply: FastifyReply, token: string) =>
@@ -515,6 +557,75 @@ function registerMultiUserAuthHook(
return false;
};
// Basic verifies in flight, by verifyFlightKey(). A request whose identical
// credentials from the same IP are already being verified awaits that verify
// instead of starting its own. Each entry is removed when its verify settles, so the
// map never holds more than the distinct verifies in flight.
const verifiesInFlight = new Map<string, Promise<BasicVerifyOutcome>>();
// The verify plus its once-only side effects, run by the first request for a key.
// A success clears the three buckets and records the login; a failure is counted
// here once (the per-IP and pair buckets already hold it: the caller reserved them).
const verifyOnce = async (
username: string,
password: string,
normUser: string,
clientIp: string,
pairKey: string
): Promise<BasicVerifyOutcome> => {
const result = await verifyPassword(username, password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
userIpFailures.delete(pairKey);
return { user };
}
// Failed guess: only the per-username bucket counts it here. Once that bucket
// reaches the cap, further FAILED attempts get 429 from any IP, but this path is
// only reached on a wrong password, so it can never deny a correct one. Its count
// is re-read after the await, so concurrent failures against one account each add one.
const uFail = (userFailures.get(normUser) ?? 0) + 1;
userFailures.set(normUser, uFail);
return { user: null, limited: uFail >= AUTH_FAILURE_MAX };
};
// Answer one request from a shared outcome. Every successful request, joiners
// included, gets its own cookie session under the usual capacity rules.
const sendBasicOutcome = (
req: FastifyRequest,
reply: FastifyReply,
outcome: BasicVerifyOutcome,
normUser: string
): void => {
if (outcome.user) {
const { user } = outcome;
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: req.ip,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
return;
}
if (outcome.limited) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
}
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
};
app.addHook('onRequest', async (req, reply) => {
const bypass = checkHookSecretBypass(req, reply, hookSecretFailures);
if (bypass === 'bypass' || bypass === 'rejected') return;
@@ -569,58 +680,69 @@ function registerMultiUserAuthHook(
return;
}
// 2. Basic Auth against the user store (scrypt verify).
// Per-IP pre-gate bounds scrypt CPU cost from one source (does NOT gate on the
// per-username bucket here; see below).
// 2. Basic Auth against the user store (scrypt verify). Three failure buckets:
// - per IP (AUTH_IP_FAILURE_MAX), checked first: bounds scrypt CPU cost from one
// source. It refuses even a correct password, so it is wider than
// AUTH_FAILURE_MAX: behind a shared egress IP (NAT, a tunnel) a narrow cap
// would let a few wrong guesses lock everyone out.
// - per (username, IP) (AUTH_FAILURE_MAX), checked BEFORE the verify: caps
// verified guesses against one account from one source. Only that IP is
// refused for that account; the same account from another IP still logs in.
// - per username (AUTH_FAILURE_MAX), checked AFTER the verify; see below.
// Neither pre-verify refusal is counted, since nothing was verified (and counting
// a locked pair's retries would fill the shared per-IP bucket for everyone).
// A request that passes both gates RESERVES its slot in both buckets before the
// scrypt await, as if it had already failed. Counting only after the verify let a
// concurrent burst pass the gates on the same stale read: 29 wrong guesses plus the
// right password, all in flight at once from one IP, logged in. A success deletes
// the reservation with the rest of the buckets; a failure is not counted again.
// A request whose IDENTICAL credentials from the same IP are already being verified
// joins that verify instead: it skips both gates, takes no slot, runs no scrypt and
// gets the same outcome, since it adds no guess and no CPU. Reserving for each of
// them refused a correct password with the slots that password itself had filled,
// which is every reload after a restart: cookie sessions live in memory, so the
// browser replays its cached Basic header on many parallel requests at once. A
// request with no verify in flight for its credentials is gated as normal.
const creds = parseBasicAuth(req.headers.authorization);
const normUser = creds ? creds.username.trim().toLowerCase() : '';
const flightKey = creds ? verifyFlightKey(clientIp, normUser, creds.password) : '';
const shared = creds ? verifiesInFlight.get(flightKey) : undefined;
if (shared) {
sendBasicOutcome(req, reply, await shared, normUser);
return;
}
const ipFail = authFailures.get(clientIp) ?? 0;
if (ipFail >= AUTH_FAILURE_MAX) {
if (ipFail >= AUTH_IP_FAILURE_MAX) {
sendAuthRateLimit(reply, authFailures, clientIp);
return;
}
const creds = parseBasicAuth(req.headers.authorization);
if (creds) {
const normUser = creds.username.trim().toLowerCase();
// Security: VERIFY FIRST, then throttle only FAILED attempts. Consulting the
// per-username bucket before verifying let throwaway IPs lock out a known account
// (incl. admin) even with the correct password. A correct password must always
// win and self-heal both buckets, regardless of the username-failure count.
const result = await verifyPassword(creds.username, creds.password);
if (result) {
const { user, needsRehash: rehash } = result;
if (rehash) void setPassword(user.username, creds.password).catch(() => {});
void touchLastLogin(user.username).catch(() => {});
authFailures.delete(clientIp);
userFailures.delete(normUser);
const token = randomBytes(32).toString('hex');
if (authSessions.size >= MAX_AUTH_SESSIONS) evictForCapacity(user.username);
authSessions.set(token, {
ip: clientIp,
ua: req.headers['user-agent'] ?? '',
createdAt: Date.now(),
method: 'basic',
username: user.username,
role: user.role,
mustChangePassword: !!user.mustChangePassword,
});
req.authUser = { username: user.username, role: user.role };
setSessionCookie(reply, token);
enforcePasswordChange(req, reply, !!user.mustChangePassword);
// IPs never contain a newline, so this key is unambiguous for any username.
const pairKey = `${clientIp}\n${normUser}`;
const pairFail = userIpFailures.get(pairKey) ?? 0;
if (pairFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userIpFailures, pairKey);
return;
}
// Failed guess: count it against BOTH buckets. Once the per-username bucket
// reaches the cap, further FAILED attempts get 429 (throttles distributed
// brute-force), but this path is only reached on a wrong password, so it can
// never deny a correct one.
const uFail = (userFailures.get(normUser) ?? 0) + 1;
userFailures.set(normUser, uFail);
// Reserve both slots and publish this verify for joiners, from the values the
// gates just read. Correct only while nothing between those reads and these
// writes awaits: keep it that way.
authFailures.set(clientIp, ipFail + 1);
if (uFail >= AUTH_FAILURE_MAX) {
sendAuthRateLimit(reply, userFailures, normUser);
return;
userIpFailures.set(pairKey, pairFail + 1);
// Security: the per-username bucket is consulted only AFTER the verify, and only
// FAILED attempts are throttled by it. Consulting it before verifying let
// throwaway IPs lock out a known account (incl. admin) even with the correct
// password. A correct password that passes the two gates above always wins and
// self-heals the per-IP, per-username and this pair's buckets.
const verify = verifyOnce(creds.username, creds.password, normUser, clientIp, pairKey);
verifiesInFlight.set(flightKey, verify);
let outcome: BasicVerifyOutcome;
try {
outcome = await verify;
} finally {
verifiesInFlight.delete(flightKey);
}
reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
reply.code(401).send('Unauthorized');
sendBasicOutcome(req, reply, outcome, normUser);
return;
}
+6
View File
@@ -457,6 +457,7 @@ export class WebServer extends EventEmitter {
private qrAuthFailures: StaleExpirationMap<string, number> | null = null;
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
private userFailures: StaleExpirationMap<string, number> | null = null;
private userIpFailures: StaleExpirationMap<string, number> | null = null;
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
@@ -959,6 +960,7 @@ export class WebServer extends EventEmitter {
this.qrAuthFailures = authState.qrAuthFailures;
this.hookSecretFailures = authState.hookSecretFailures;
this.userFailures = authState.userFailures;
this.userIpFailures = authState.userIpFailures;
}
// WebSocket support (terminal I/O — low-latency bidirectional channel)
@@ -4324,6 +4326,10 @@ export class WebServer extends EventEmitter {
this.userFailures.dispose();
this.userFailures = null;
}
if (this.userIpFailures) {
this.userIpFailures.dispose();
this.userIpFailures = null;
}
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
this.killingSessions.clear();