fix: close workflow console review remediation

This commit is contained in:
lda
2026-08-12 14:51:02 +07:00 Verified
parent e0e1b718b8
commit a9bb2ab837
22 changed files with 359 additions and 178 deletions
+21
View File
@@ -225,6 +225,27 @@ describe("console POST request boundary", () => {
expect(res.status).toBe(200);
expect(runOperation).toHaveBeenCalledTimes(1);
});
it("allows an exact configured origin behind a TLS-terminating proxy", async () => {
const runOperation = vi.fn<RunOperation>(async (operation) => makeExchange({ operation }));
const guardedApp = makeApp({
runOperation,
trustedOrigins: new Set(["https://console.example"]),
});
const res = await guardedApp.request("http://internal:8787/api/connect", {
method: "POST",
headers: {
...validConsoleHeaders,
origin: "https://console.example",
"sec-fetch-site": "same-origin",
},
body: JSON.stringify({ target: "http://127.0.0.1:8765/rpc" }),
});
expect(res.status).toBe(200);
expect(runOperation).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/rpc", () => {
+11 -4
View File
@@ -44,6 +44,7 @@ type ConsoleRequestRejection = {
*/
const consoleRequestRejection = (
request: Request,
trustedOrigins: ReadonlySet<string>,
): ConsoleRequestRejection | null => {
const contentType = request.headers
.get("content-type")
@@ -80,7 +81,10 @@ const consoleRequestRejection = (
} catch {
return { status: 403, message: "invalid console request origin" };
}
if (parsedOrigin.origin !== new URL(request.url).origin) {
if (
parsedOrigin.origin !== new URL(request.url).origin &&
!trustedOrigins.has(parsedOrigin.origin)
) {
return { status: 403, message: "cross-origin console POST rejected" };
}
}
@@ -89,6 +93,7 @@ const consoleRequestRejection = (
const rejectInvalidConsoleRequest = (
request: Request,
trustedOrigins: ReadonlySet<string>,
):
| {
readonly status: 403 | 415;
@@ -105,7 +110,7 @@ const rejectInvalidConsoleRequest = (
};
}
| null => {
const rejection = consoleRequestRejection(request);
const rejection = consoleRequestRejection(request, trustedOrigins);
if (rejection === null) return null;
return {
status: rejection.status,
@@ -159,12 +164,14 @@ export function createApp(dependencies: {
readonly upgradeWebSocket: typeof upgradeWebSocket;
};
readonly browserOperationPolicy: BrowserOperationPolicy;
readonly trustedOrigins?: ReadonlySet<string>;
readonly consoleRoot?: string;
}): Hono {
const {
runOperation,
presentationSync,
browserOperationPolicy,
trustedOrigins = new Set(),
consoleRoot,
} = dependencies;
const app = new Hono();
@@ -177,7 +184,7 @@ export function createApp(dependencies: {
app.use("/api/connect", bodyLimit({ maxSize: 256 * 1024 }));
app.post("/api/connect", async (c) => {
const rejected = rejectInvalidConsoleRequest(c.req.raw);
const rejected = rejectInvalidConsoleRequest(c.req.raw, trustedOrigins);
if (rejected !== null) return c.json(rejected.body, rejected.status);
let body: { target?: string };
@@ -254,7 +261,7 @@ export function createApp(dependencies: {
app.use("/api/rpc", bodyLimit({ maxSize: 256 * 1024 }));
app.post("/api/rpc", async (c) => {
const rejected = rejectInvalidConsoleRequest(c.req.raw);
const rejected = rejectInvalidConsoleRequest(c.req.raw, trustedOrigins);
if (rejected !== null) return c.json(rejected.body, rejected.status);
let body: { operation?: string; target?: string; params?: unknown };
+10
View File
@@ -20,6 +20,7 @@ import {
} from "./browser-operation-policy.js";
import { createPresentationRoomService } from "./presentation-sync/rooms.js";
import { shutdownServer } from "./shutdown.js";
import { parseTrustedOrigins } from "./trusted-origins.js";
import { WebSocketServer } from "ws";
const port = Number(process.env.WEB_PORT ?? "8787");
@@ -37,6 +38,14 @@ const capabilityCallsEnabled = capabilityCallsEnabledForHost(
const browserOperationPolicy = createBrowserOperationPolicy({
enableCapabilityCalls: capabilityCallsEnabled,
});
let trustedOrigins: ReadonlySet<string>;
try {
trustedOrigins = parseTrustedOrigins(process.env.WEB_TRUSTED_ORIGINS);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Invalid WEB_TRUSTED_ORIGINS: ${message}`);
process.exit(1);
}
if (
capabilityCallsOverride === "1" &&
!capabilityCallsEnabledForHost(hostname, undefined)
@@ -74,6 +83,7 @@ try {
runOperation,
presentationSync: { rooms, upgradeWebSocket },
browserOperationPolicy,
trustedOrigins,
...(staticConsoleRoot ? { consoleRoot: staticConsoleRoot } : {}),
});
} catch (error) {
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { parseTrustedOrigins } from "./trusted-origins.js";
describe("parseTrustedOrigins", () => {
it("parses and deduplicates exact origins", () => {
expect([
...parseTrustedOrigins("https://console.example/, http://localhost:8787,https://console.example"),
]).toEqual(["https://console.example", "http://localhost:8787"]);
});
it.each(["https://console.example/path", "https://console.example?mode=1", "not a URL"])(
"rejects a value that is not an exact origin: %s",
(value) => {
expect(() => parseTrustedOrigins(value)).toThrow("Invalid trusted origin");
},
);
});
+27
View File
@@ -0,0 +1,27 @@
/** Parses an explicit comma-separated browser-origin allowlist. */
export const parseTrustedOrigins = (raw: string | undefined): ReadonlySet<string> => {
if (raw === undefined || raw.trim() === "") return new Set();
const origins = new Set<string>();
for (const entry of raw.split(",")) {
const value = entry.trim();
if (value === "") continue;
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`Invalid trusted origin: ${value}`);
}
if (
!["http:", "https:"].includes(parsed.protocol) ||
parsed.pathname !== "/" ||
parsed.search !== "" ||
parsed.hash !== "" ||
parsed.username !== "" ||
parsed.password !== ""
) {
throw new Error(`Invalid trusted origin: ${value}`);
}
origins.add(parsed.origin);
}
return origins;
};