mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
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:
commit
053615f9ae
15 files changed
+489
-73
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user