fix(docker): require a per-session token on the VNC browser CDP bridge (#12571) (#13241)

Merged as part of the owner batch of 2026-09-11.

This PR had a live worktree in another session, so it sat outside the main 39. Merged on your explicit call, validated first rather than taken on trust: boarded with the other 10 worktree-held PRs into a consolidated worktree off `release/v3.8.51`.

- ESLint over every changed file: no errors
- `typecheck:core` clean; `check:dashboard-typecheck` OK; `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437
- 203 of 208 assertions green. The 5 remaining (`guide-settings-route` ×4, `hard-session-lease-bypass-inventory` ×1) reproduce on the pure tip with nothing from this batch applied.
- `imageGeneration.ts` rebaselined 3259 → 3293 for #12945's image-only-model guard, landed separately in #13392 so nothing was pushed onto a live branch.

⚠️ base-red inherited: #12732 — provider count 356 vs 358 and `open-sse/utils/stream.ts` 3115 > frozen 3098, both reproducing on the pure tip.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-11 22:29:05 -03:00
committed by GitHub
parent 1fb7d5dff2
commit b97bc59f4f
10 changed files with 327 additions and 38 deletions

View File

@@ -17,6 +17,9 @@ export interface HarvestResult {
hasCredential: boolean;
}
/** Header name the CDP bridge (docker/vnc-browser/chromium/cdp-bridge.py) requires (#12571). */
const CDP_TOKEN_HEADER = "X-Omni-Cdp-Token";
interface Pending {
resolve: (value: any) => void;
reject: (error: Error) => void;
@@ -36,8 +39,8 @@ class CdpClient {
private sessionId: string | null = null;
private closed = false;
constructor(wsUrl: string) {
this.ws = new WebSocket(wsUrl);
constructor(wsUrl: string, cdpToken: string) {
this.ws = new WebSocket(wsUrl, { headers: { [CDP_TOKEN_HEADER]: cdpToken } });
this.ws.on("message", (data) => this.onMessage(data));
this.ws.on("close", () => this.rejectAll(new Error("CDP websocket closed")));
this.ws.on("error", (error) => this.rejectAll(toError(error, "CDP websocket error")));
@@ -252,7 +255,11 @@ class CdpClient {
}
}
export async function waitForCdpReady(cdpPort: number, timeoutMs: number): Promise<void> {
export async function waitForCdpReady(
cdpPort: number,
timeoutMs: number,
cdpToken: string
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let lastError: Error | null = null;
@@ -260,7 +267,11 @@ export async function waitForCdpReady(cdpPort: number, timeoutMs: number): Promi
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2_000);
try {
const version = await fetchJson(`http://127.0.0.1:${cdpPort}/json/version`, controller.signal);
const version = await fetchJson(
`http://127.0.0.1:${cdpPort}/json/version`,
controller.signal,
cdpToken
);
if (version?.webSocketDebuggerUrl) return;
lastError = new Error("CDP endpoint did not return a websocket URL");
} catch (error) {
@@ -277,7 +288,8 @@ export async function waitForCdpReady(cdpPort: number, timeoutMs: number): Promi
export async function harvestFromContainer(
cdpPort: number,
provider: VncProviderEntry,
timeoutMs = 20_000
timeoutMs = 20_000,
cdpToken = ""
): Promise<HarvestResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -286,14 +298,15 @@ export async function harvestFromContainer(
try {
const version = await fetchJson(
`http://127.0.0.1:${cdpPort}/json/version`,
controller.signal
controller.signal,
cdpToken
);
const debuggerUrl = version?.webSocketDebuggerUrl;
if (typeof debuggerUrl !== "string" || !debuggerUrl) {
throw new Error("No CDP websocket endpoint from browser container");
}
client = new CdpClient(rewriteDebuggerUrl(debuggerUrl, cdpPort));
client = new CdpClient(rewriteDebuggerUrl(debuggerUrl, cdpPort), cdpToken);
await client.ready(Math.min(timeoutMs, 15_000), controller.signal);
const origin = new URL(provider.url).origin;
@@ -403,8 +416,8 @@ function safeOrigin(value: string | undefined): string | null {
}
}
async function fetchJson(url: string, signal: AbortSignal): Promise<any> {
const response = await fetch(url, { signal });
async function fetchJson(url: string, signal: AbortSignal, cdpToken = ""): Promise<any> {
const response = await fetch(url, { signal, headers: { [CDP_TOKEN_HEADER]: cdpToken } });
if (!response.ok) throw new Error(`CDP endpoint returned HTTP ${response.status}`);
return response.json();
}

View File

@@ -86,6 +86,12 @@ export const VNC_CONFIG = {
maxSessionMs: Number(process.env.OMNIROUTE_VNC_MAX_MS || 30 * 60 * 1000),
maxSessions: Number(process.env.OMNIROUTE_VNC_MAX_SESSIONS || 4),
dockerBin: process.env.OMNIROUTE_DOCKER_BIN || "docker",
/**
* Dedicated bridge network for browser-login containers (#12571): keeps
* them off Docker's default bridge network so sibling containers can't
* reach the CDP bridge port over the container-to-container path.
*/
network: process.env.OMNIROUTE_VNC_NETWORK || "omniroute-vnc-browser-login",
browserReadyTimeoutMs: Number(process.env.OMNIROUTE_VNC_READY_MS || 45_000),
harvestTimeoutMs: Number(process.env.OMNIROUTE_VNC_HARVEST_MS || 20_000),
chromiumArgs:

View File

@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { chmodSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
@@ -17,6 +17,8 @@ export interface VncSession {
containerName: string;
profileDir: string;
cdpPort: number;
/** Shared secret the CDP bridge (docker/vnc-browser/chromium/cdp-bridge.py) requires (#12571). */
cdpToken: string;
vncPort: number;
url: string;
status: VncSessionStatus;
@@ -138,6 +140,67 @@ function createProfileDir(connectionId: string, sessionId: string): string {
return profileDir;
}
let networkEnsured = false;
/**
* Creates the dedicated browser-login bridge network (#12571) if it does not
* already exist. Idempotent: `docker network create` failing because the
* network is already there is not an error.
*/
async function ensureNetwork(): Promise<void> {
if (networkEnsured) return;
const result = await docker(["network", "create", VNC_CONFIG.network], { timeoutMs: 15_000 });
if (result.code !== 0 && !/already exists/i.test(result.err)) {
throw new Error(result.err.trim() || `Could not create Docker network ${VNC_CONFIG.network}`);
}
networkEnsured = true;
}
/**
* Builds the `docker run` argument array for a browser-login container.
* Pulled out as a pure function so the security-relevant shape (dedicated
* network + CDP_BRIDGE_TOKEN, #12571) is directly testable without spawning
* Docker or touching the DB.
*/
export function buildRunArgs(params: {
containerName: string;
sessionId: string;
connectionId: string;
profileDir: string;
chromeCli: string;
cdpToken: string;
}): string[] {
return [
"run",
"-d",
"--name",
params.containerName,
"--restart",
"no",
"--network",
VNC_CONFIG.network,
"--label",
`${LABEL}=true`,
"--label",
`${LABEL}.session-id=${params.sessionId}`,
"--label",
`${LABEL}.connection-id=${params.connectionId}`,
"--shm-size",
"1gb",
"-p",
`127.0.0.1::${VNC_CONFIG.containerVncPort}`,
"-p",
`127.0.0.1::${VNC_CONFIG.containerCdpPort}`,
"-v",
`${params.profileDir}:${VNC_CONFIG.containerProfileDir}`,
"-e",
`CHROME_CLI=${params.chromeCli}`,
"-e",
`CDP_BRIDGE_TOKEN=${params.cdpToken}`,
VNC_CONFIG.image,
];
}
async function publishedPort(containerName: string, containerPort: number): Promise<number> {
const result = await docker(["port", containerName, `${containerPort}/tcp`], {
timeoutMs: 10_000,
@@ -176,6 +239,7 @@ export async function startSession(connectionId: string): Promise<VncSession> {
const sessionId = randomUUID();
const containerName = sessionKey(sessionId);
const profileDir = createProfileDir(connectionId, sessionId);
const cdpToken = randomBytes(24).toString("hex");
const state: VncSession = {
sessionId,
connectionId,
@@ -183,6 +247,7 @@ export async function startSession(connectionId: string): Promise<VncSession> {
containerName,
profileDir,
cdpPort: 0,
cdpToken,
vncPort: 0,
url: provider.url,
status: "starting",
@@ -193,33 +258,10 @@ export async function startSession(connectionId: string): Promise<VncSession> {
SESSIONS.set(sessionId, state);
try {
await ensureNetwork();
const chromeCli = `${VNC_CONFIG.chromiumArgs} ${provider.url}`;
const result = await docker(
[
"run",
"-d",
"--name",
containerName,
"--restart",
"no",
"--label",
`${LABEL}=true`,
"--label",
`${LABEL}.session-id=${sessionId}`,
"--label",
`${LABEL}.connection-id=${connectionId}`,
"--shm-size",
"1gb",
"-p",
`127.0.0.1::${VNC_CONFIG.containerVncPort}`,
"-p",
`127.0.0.1::${VNC_CONFIG.containerCdpPort}`,
"-v",
`${profileDir}:${VNC_CONFIG.containerProfileDir}`,
"-e",
`CHROME_CLI=${chromeCli}`,
VNC_CONFIG.image,
],
buildRunArgs({ containerName, sessionId, connectionId, profileDir, chromeCli, cdpToken }),
{ timeoutMs: 120_000 }
);
if (result.code !== 0) {
@@ -234,7 +276,7 @@ export async function startSession(connectionId: string): Promise<VncSession> {
state.vncPort = await publishedPort(containerName, VNC_CONFIG.containerVncPort);
state.cdpPort = await publishedPort(containerName, VNC_CONFIG.containerCdpPort);
await waitForCdpReady(state.cdpPort, VNC_CONFIG.browserReadyTimeoutMs);
await waitForCdpReady(state.cdpPort, VNC_CONFIG.browserReadyTimeoutMs, state.cdpToken);
state.status = "running";
scheduleIdleSweep();
@@ -275,7 +317,8 @@ export async function harvestSession(
const harvest = await harvestFromContainer(
session.cdpPort,
provider,
VNC_CONFIG.harvestTimeoutMs
VNC_CONFIG.harvestTimeoutMs,
session.cdpToken
);
session.lastHarvestAt = Date.now();
if (!harvest.hasCredential) {