fix(dashboard): Playground Compare tab loading + HTTP method guard (#4024)

randomUUID non-HTTPS fallback + static CompareTab import; raw HTTP TRACE->405 method guard wired into dev + standalone servers. Integrated into release/v3.8.27.
This commit is contained in:
Randi
2026-06-16 17:46:09 -04:00
committed by GitHub
parent ad067a193e
commit e68cd47470
11 changed files with 310 additions and 30 deletions

View File

@@ -0,0 +1,82 @@
"use strict";
const http = require("node:http");
const HIGH_RISK_METHOD_RULES = [
[/^\/api\/auth\/login\/?$/, ["POST"]],
[/^\/api\/auth\/logout\/?$/, ["POST"]],
[/^\/api\/keys\/?$/, ["GET", "POST"]],
[/^\/api\/keys\/[^/]+\/?$/, ["GET", "PATCH", "DELETE"]],
];
let installed = false;
function getPathname(req) {
const rawUrl = typeof req?.url === "string" && req.url ? req.url : "/";
try {
return new URL(rawUrl, "http://localhost").pathname;
} catch {
return rawUrl.split("?")[0] || "/";
}
}
function getAllowedMethods(pathname) {
for (const [pattern, methods] of HIGH_RISK_METHOD_RULES) {
if (pattern.test(pathname)) return methods;
}
return null;
}
function getAllowHeader(pathname) {
const methods = getAllowedMethods(pathname);
return methods ? methods.join(", ") : null;
}
function maybeHandleDisallowedMethod(req, res) {
const method = typeof req?.method === "string" ? req.method.toUpperCase() : "";
const pathname = getPathname(req);
const methods = getAllowedMethods(pathname);
if (!methods || method === "OPTIONS" || methods.includes(method)) return false;
res.statusCode = 405;
res.setHeader("Allow", methods.join(", "));
res.setHeader("Cache-Control", "no-store");
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
error: {
code: "METHOD_NOT_ALLOWED",
message: `${method || "Method"} is not allowed`,
},
})
);
return true;
}
function wrapRequestListenerWithMethodGuard(listener) {
return function methodGuardRequestHandler(req, res) {
if (maybeHandleDisallowedMethod(req, res)) return;
return listener.call(this, req, res);
};
}
function installHttpMethodGuard() {
if (installed) return;
installed = true;
const originalCreateServer = http.createServer.bind(http);
http.createServer = function createServerWithMethodGuard(...args) {
const lastFnIdx = args.map((arg) => typeof arg === "function").lastIndexOf(true);
if (lastFnIdx >= 0) {
args[lastFnIdx] = wrapRequestListenerWithMethodGuard(args[lastFnIdx]);
}
return originalCreateServer(...args);
};
}
module.exports = {
getAllowHeader,
maybeHandleDisallowedMethod,
wrapRequestListenerWithMethodGuard,
installHttpMethodGuard,
};

View File

@@ -9,9 +9,12 @@ import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mj
import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
import methodGuard from "./http-method-guard.cjs";
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
import { randomUUID } from "node:crypto";
const { maybeHandleDisallowedMethod } = methodGuard;
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
if (!process.env.DATA_DIR) {
try {
@@ -83,6 +86,7 @@ async function start() {
});
const server = http.createServer((req, res) => {
if (maybeHandleDisallowedMethod(req, res)) return;
// Stamp the real TCP peer IP before Next sees the request, so the authz
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
stampPeerIp(req);

View File

@@ -3,9 +3,11 @@ import { randomUUID } from "node:crypto";
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
import { maybeHandleWebdav } from "./webdav-handler.mjs";
import methodGuard from "./http-method-guard.cjs";
const originalCreateServer = http.createServer.bind(http);
const proxiesByPort = new Map();
const { wrapRequestListenerWithMethodGuard } = methodGuard;
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret proving the trusted peer-IP stamp came from this server.
@@ -71,9 +73,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
// createServer; wrap it so the real TCP peer IP is stamped before Next runs.
const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true);
if (lastFnIdx >= 0) {
// WebDAV intercept wraps outermost (first to run), then peer-stamp, then Next.
args[lastFnIdx] = wrapRequestListenerWithWebdav(
wrapRequestListenerWithPeerStamp(args[lastFnIdx])
// Method guard runs before Next because Next 16 rejects TRACE while constructing requests.
args[lastFnIdx] = wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx]))
);
}
@@ -89,7 +91,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "request" && typeof listener === "function") {
return originalOn(
eventName,
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
)
);
}
return originalOn(eventName, listener);
@@ -102,7 +106,9 @@ http.createServer = function createServerWithResponsesWs(...args) {
if (eventName === "request" && typeof listener === "function") {
return originalAddListener(
eventName,
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
wrapRequestListenerWithMethodGuard(
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
)
);
}
return originalAddListener(eventName, listener);