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:
+76
-10
@@ -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 };
|
||||
|
||||
|
||||
+66
-2
@@ -1,5 +1,12 @@
|
||||
const { Buffer } = require("node:buffer");
|
||||
const { WebSocket, WebSocketServer } = require("ws");
|
||||
|
||||
/** Maximum frame payload size (256 KB). */
|
||||
const MAX_FRAME_SIZE = 256 * 1024;
|
||||
|
||||
/** Maximum frames per connection per second. */
|
||||
const MAX_FRAMES_PER_SECOND = 30;
|
||||
|
||||
const buildErrorResponse = (id, code, message) => {
|
||||
return {
|
||||
type: "res",
|
||||
@@ -19,6 +26,39 @@ const safeJsonParse = (raw) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** Per-connection frame rate limiter. */
|
||||
const createFrameRateLimiter = (maxPerSecond = MAX_FRAMES_PER_SECOND) => {
|
||||
let count = 0;
|
||||
const interval = setInterval(() => { count = 0; }, 1000);
|
||||
interval.unref();
|
||||
return {
|
||||
check() { return ++count <= maxPerSecond; },
|
||||
destroy() { clearInterval(interval); },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate upstream URL against an allowlist.
|
||||
* If UPSTREAM_ALLOWLIST env var is set, only those hosts are permitted.
|
||||
* Format: comma-separated hostnames, e.g. "gateway.percival-labs.ai,localhost"
|
||||
*/
|
||||
const isUpstreamAllowed = (url) => {
|
||||
const allowlist = (process.env.UPSTREAM_ALLOWLIST || "").trim();
|
||||
if (!allowlist) {
|
||||
return process.env.NODE_ENV !== "production";
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const allowed = allowlist
|
||||
.split(",")
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return allowed.includes(parsed.hostname.toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvePathname = (url) => {
|
||||
const raw = typeof url === "string" ? url : "";
|
||||
const idx = raw.indexOf("?");
|
||||
@@ -106,10 +146,11 @@ function createGatewayProxy(options) {
|
||||
let pendingConnectFrame = null;
|
||||
let pendingUpstreamSetupError = null;
|
||||
let closed = false;
|
||||
|
||||
const frameRateLimiter = createFrameRateLimiter();
|
||||
const closeBoth = (code, reason) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
frameRateLimiter.destroy();
|
||||
try {
|
||||
browserWs.close(code, reason);
|
||||
} catch {}
|
||||
@@ -191,6 +232,14 @@ function createGatewayProxy(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isUpstreamAllowed(upstreamUrl)) {
|
||||
pendingUpstreamSetupError = {
|
||||
code: "studio.gateway_url_blocked",
|
||||
message: "Upstream gateway URL is not in the allowed hosts list.",
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let upstreamOrigin = "";
|
||||
try {
|
||||
upstreamOrigin = resolveOriginForUpstream(upstreamUrl);
|
||||
@@ -250,7 +299,22 @@ function createGatewayProxy(options) {
|
||||
void startUpstream();
|
||||
|
||||
browserWs.on("message", async (raw) => {
|
||||
const parsed = safeJsonParse(String(raw ?? ""));
|
||||
const rawStr = String(raw ?? "");
|
||||
const rawByteLength = Buffer.byteLength(rawStr, "utf8");
|
||||
|
||||
// Frame size limit
|
||||
if (rawByteLength > MAX_FRAME_SIZE) {
|
||||
closeBoth(1009, "frame too large");
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (!frameRateLimiter.check()) {
|
||||
closeBoth(1008, "rate limit exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = safeJsonParse(rawStr);
|
||||
if (!parsed || !isObject(parsed)) {
|
||||
closeBoth(1003, "invalid json");
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user