aboutsummaryrefslogtreecommitdiffstats
path: root/gateway/src/auth.js
diff options
context:
space:
mode:
Diffstat (limited to 'gateway/src/auth.js')
-rw-r--r--gateway/src/auth.js83
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;
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage