security: harden gateway proxy, custom runtime proxy, and media routes (#95)

* security hardening pass 1 - otel removed

* hardening pass #2

* feat security hardening pass

* chore: trim unrelated docs from security hardening pr

* fix: address security hardening review findings

* address findings
This commit is contained in:
gsknnft
2026-04-03 18:02:06 -04:00
committed by GitHub
parent 083c146aac
commit 051d0ce469
14 changed files with 572 additions and 30 deletions
+76 -10
View File
@@ -1,3 +1,6 @@
const crypto = require("node:crypto");
const parseCookies = (header) => {
const raw = typeof header === "string" ? header : "";
if (!raw.trim()) return {};
@@ -13,34 +16,98 @@ const parseCookies = (header) => {
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 isAuthorized = (req) => {
if (!enabled) return true;
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);
return cookies[cookieName] === token;
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;
if (!isAuthorized(req)) {
const auth = getAuthState(req);
if (!auth.authorized) {
const statusCode = auth.limited ? 429 : 401;
if (String(req.url || "/").startsWith("/api/")) {
res.statusCode = 401;
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
error: "Studio access token required. Send the configured Studio access cookie and retry.",
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 = 401;
res.statusCode = statusCode;
res.setHeader("Content-Type", "text/plain");
res.end("Studio access token required. Set the studio_access cookie to access this page.");
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;
}
@@ -49,11 +116,10 @@ function createAccessGate(options) {
const allowUpgrade = (req) => {
if (!enabled) return true;
return isAuthorized(req);
return getAuthState(req).authorized;
};
return { enabled, handleHttp, allowUpgrade };
}
module.exports = { createAccessGate };