diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
| commit | 96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch) | |
| tree | b2164a76dc3aa5b69e160d1135d3385bcbe6108d /gateway/src/auth.js | |
| parent | 150858067059ba615c548f826bb343589993b4bb (diff) | |
ts web slopify code
Diffstat (limited to 'gateway/src/auth.js')
| -rw-r--r-- | gateway/src/auth.js | 83 |
1 files changed, 83 insertions, 0 deletions
diff --git a/gateway/src/auth.js b/gateway/src/auth.js new file mode 100644 index 0000000..288de90 --- /dev/null +++ b/gateway/src/auth.js @@ -0,0 +1,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; +} |
