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
+66 -2
View File
@@ -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;