const crypto = require("node:crypto"); const parseCookies = (header) => { const raw = typeof header === "string" ? header : ""; if (!raw.trim()) return {}; const out = {}; for (const part of raw.split(";")) { const idx = part.indexOf("="); if (idx === -1) continue; const key = part.slice(0, idx).trim(); const value = part.slice(idx + 1).trim(); if (!key) continue; out[key] = value; } return out; }; /** Constant-time string comparison to prevent timing attacks. */ const safeCompare = (a, b) => { if (typeof a !== "string" || typeof b !== "string") return false; const bufA = Buffer.from(a, "utf8"); const bufB = Buffer.from(b, "utf8"); if (bufA.length !== bufB.length) { // Compare against self to burn constant time, then return false crypto.timingSafeEqual(bufA, bufA); return false; } return crypto.timingSafeEqual(bufA, bufB); }; /** Simple in-memory rate limiter for auth attempts. */ const createRateLimiter = (maxAttempts = 10, windowMs = 60_000) => { const attempts = new Map(); const cleanup = setInterval(() => { const now = Date.now(); for (const [key, entry] of attempts) { if (now - entry.start > windowMs) attempts.delete(key); } }, windowMs); cleanup.unref(); return { isLimited(ip) { const entry = attempts.get(ip); if (!entry) return false; return entry.count >= maxAttempts; }, recordFailure(ip) { const now = Date.now(); const entry = attempts.get(ip); if (!entry || now - entry.start > windowMs) { attempts.set(ip, { count: 1, start: now }); return; } entry.count++; }, reset(ip) { attempts.delete(ip); }, }; }; function createAccessGate(options) { const token = String(options?.token ?? "").trim(); const cookieName = String(options?.cookieName ?? "studio_access").trim() || "studio_access"; const enabled = Boolean(token); const rateLimiter = createRateLimiter(10, 60_000); const getAuthState = (req) => { if (!enabled) return { authorized: true, limited: false }; const ip = req.socket?.remoteAddress || "unknown"; const cookieHeader = req.headers?.cookie; const cookies = parseCookies(cookieHeader); const authorized = safeCompare(cookies[cookieName] || "", token); if (authorized) { rateLimiter.reset(ip); return { authorized: true, limited: false }; } if (rateLimiter.isLimited(ip)) { return { authorized: false, limited: true }; } rateLimiter.recordFailure(ip); return { authorized: false, limited: rateLimiter.isLimited(ip) }; }; const handleHttp = (req, res) => { if (!enabled) return false; const auth = getAuthState(req); if (!auth.authorized) { const statusCode = auth.limited ? 429 : 401; if (String(req.url || "/").startsWith("/api/")) { res.statusCode = statusCode; res.setHeader("Content-Type", "application/json"); res.end( JSON.stringify({ error: auth.limited ? "Too many failed studio access attempts. Wait a minute and retry." : "Studio access token required. Send the configured Studio access cookie and retry.", }) ); } else { res.statusCode = statusCode; res.setHeader("Content-Type", "text/plain"); res.end( auth.limited ? "Too many failed studio access attempts. Wait a minute and retry." : "Studio access token required. Set the studio_access cookie to access this page." ); } return true; } return false; }; const allowUpgrade = (req) => { if (!enabled) return true; return getAuthState(req).authorized; }; return { enabled, handleHttp, allowUpgrade }; } module.exports = { createAccessGate };