security(relay): minor hardening follow-ups from PR review

- Uniform auth responses remove the session-enumeration oracle: unknown
  session and wrong password now return an identical 401 at the API layer and
  an identical WS handshake/close; desktop-status and the 503 "not connected"
  signal are only exposed after successful auth.
- Session-count cap re-checked after bcrypt.hash so concurrent creates can't
  overshoot maxSessions.
- Relay web client reconnect backoff resets only after a successful auth
  response (an accept-then-close server no longer defeats the backoff).
- idGenerator comment corrected to match the rejection-sampling; drop unused
  recordFailure return value.
- DEPLOY.md: document ALLOWED_ORIGINS for reverse-proxy deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:13:33 -07:00
parent 74bd3c2e98
commit b5e5bed7dd
7 changed files with 78 additions and 41 deletions
+5 -5
View File
@@ -33,10 +33,12 @@ export function getLockoutRemaining(key: string): number {
}
/**
* Record a failed auth attempt. Returns the lockout remaining (ms) after
* this failure is applied (0 if still not locked).
* Record a failed auth attempt. Locks the key out for
* `authLockoutDurationMs` once `authLockoutMaxAttempts` failures accumulate
* within `authLockoutWindowMs`. Callers query the resulting lockout via
* getLockoutRemaining(), so this returns nothing.
*/
export function recordFailure(key: string): number {
export function recordFailure(key: string): void {
const now = Date.now();
let rec = records.get(key);
@@ -51,8 +53,6 @@ export function recordFailure(key: string): number {
if (rec.count >= config.authLockoutMaxAttempts) {
rec.lockedUntil = now + config.authLockoutDurationMs;
}
return rec.lockedUntil > now ? rec.lockedUntil - now : 0;
}
/**
+5 -1
View File
@@ -18,7 +18,11 @@ export function generateSessionId(length: number = 12): string {
let result = '';
while (result.length < length) {
// Over-fetch a little to reduce the number of syscalls when rejecting.
// Fetch exactly `length` random bytes per pass. Bytes at or above the
// rejection threshold are skipped to avoid modulo bias, so a pass may
// yield fewer than `length` characters; the outer while then runs another
// pass until we have enough. (No over-fetching — each pass draws exactly
// `length` bytes.)
const bytes = randomBytes(length);
for (let i = 0; i < bytes.length && result.length < length; i++) {
const byte = bytes[i];