aboutsummaryrefslogtreecommitdiffstats
path: root/gateway/src/auth.js
blob: 288de90abfad1bac4b7b115780dd8be36b72a9b2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// Access control for browser voice: server-password check and brute-force limits.
import { createHash, timingSafeEqual } from "node:crypto";

const digest = (value) => createHash("sha256").update(String(value), "utf8").digest();

/**
 * Constant-time comparison of the typed password against the configured one.
 * An unset/empty configured password never matches: voice requires a password.
 */
export function passwordMatches(input, expected) {
  if (typeof input !== "string" || typeof expected !== "string" || expected.length === 0) return false;
  if (input.length > 512) return false;
  return timingSafeEqual(digest(input), digest(expected));
}

/** Per-IP failed-login tracking with a temporary lockout. */
export class LoginLimiter {
  constructor({ maxFailures = 5, lockoutMs = 10 * 60_000, maxTracked = 4096 } = {}, clock = Date.now) {
    this.maxFailures = maxFailures;
    this.lockoutMs = lockoutMs;
    this.maxTracked = maxTracked;
    this.clock = clock;
    this.peers = new Map(); // ip -> { failures, firstAt, lockedUntil }
  }

  /** Milliseconds the peer is still locked out for (0 = allowed). */
  lockedFor(ip) {
    const entry = this.peers.get(ip);
    if (!entry) return 0;
    const now = this.clock();
    if (entry.lockedUntil > now) return entry.lockedUntil - now;
    if (now - entry.firstAt > this.lockoutMs) this.peers.delete(ip);
    return 0;
  }

  recordFailure(ip) {
    const now = this.clock();
    let entry = this.peers.get(ip);
    if (!entry || now - entry.firstAt > this.lockoutMs) {
      this.prune(now);
      entry = { failures: 0, firstAt: now, lockedUntil: 0 };
      this.peers.set(ip, entry);
    }
    entry.failures += 1;
    if (entry.failures >= this.maxFailures) entry.lockedUntil = now + this.lockoutMs;
  }

  recordSuccess(ip) {
    this.peers.delete(ip);
  }

  prune(now) {
    for (const [ip, entry] of this.peers) {
      if (entry.lockedUntil <= now && now - entry.firstAt > this.lockoutMs) this.peers.delete(ip);
    }
    while (this.peers.size >= this.maxTracked) {
      const oldest = this.peers.keys().next().value;
      if (oldest === undefined) break;
      this.peers.delete(oldest);
    }
  }
}

/** Same-host Origin check (plus configured extra origins) for the voice WebSocket. */
export function originAllowed(origin, host, allowedOrigins = []) {
  if (!origin) return false; // browsers always send Origin on WebSocket upgrades
  const normalized = origin.replace(/\/+$/, "").toLowerCase();
  if (allowedOrigins.includes("*") || allowedOrigins.includes(normalized)) return true;
  try {
    return new URL(normalized).host === String(host ?? "").toLowerCase();
  } catch {
    return false;
  }
}

/** Trim, drop control characters and enforce TeamSpeak's 3..30 character nickname rule. */
export function sanitizeNickname(input, prefix = "") {
  if (typeof input !== "string") return null;
  const cleaned = input.replace(/[\u0000-\u001f\u007f]/g, "").replace(/\s+/g, " ").trim();
  if (!cleaned) return null;
  const full = `${prefix}${cleaned}`.slice(0, 30).trim();
  return [...full].length >= 3 ? full : null;
}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage