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;
}
|