mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
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:
committed by
GitHub
parent
1fb7d5dff2
commit
b97bc59f4f
@@ -3021,6 +3021,7 @@ QUOTA_STORE_DRIVER=sqlite
|
||||
# OMNIROUTE_VNC_READY_MS=45000
|
||||
# OMNIROUTE_VNC_HARVEST_MS=20000
|
||||
# OMNIROUTE_VNC_CHROMIUM_ARGS=--remote-debugging-port=9222 --no-first-run --no-default-browser-check
|
||||
# OMNIROUTE_VNC_NETWORK=omniroute-vnc-browser-login
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Data-dir alias (optional — open-sse/services/notionThreadSessions.ts)
|
||||
|
||||
1
changelog.d/fixes/12571-vnc-cdp-bridge-auth.md
Normal file
1
changelog.d/fixes/12571-vnc-cdp-bridge-auth.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(docker): require a per-session token on the VNC browser CDP bridge and isolate it on a dedicated Docker network (#12571)
|
||||
@@ -8,6 +8,14 @@
|
||||
# Chrome 150 ignores --remote-debugging-address and binds loopback only.
|
||||
# The OmniRoute server harvests cookies over the host-mapped 9223.
|
||||
#
|
||||
# SECURITY (#12571): 9223 is gated by a per-session shared secret
|
||||
# (CDP_BRIDGE_TOKEN, injected via `-e` by src/lib/vncSession/service.ts) that
|
||||
# every caller must present as an `X-Omni-Cdp-Token` header before the bridge
|
||||
# forwards a single byte to Chromium — see cdp-bridge.py for the check. The
|
||||
# container also runs on a dedicated Docker network (not the default bridge)
|
||||
# so sibling containers can't reach 9223 either. Do not remove either control
|
||||
# or the CDP bridge reverts to an unauthenticated, full-session-takeover proxy.
|
||||
#
|
||||
# Alpine/Debian package mirrors are unreachable from the build sandbox, so we
|
||||
# extend a prebuilt image rather than apt/apk-installing anything.
|
||||
FROM linuxserver/chromium:latest
|
||||
|
||||
@@ -5,19 +5,64 @@ Chrome binds DevTools to 127.0.0.1 only and ignores --remote-debugging-address
|
||||
on recent versions, so the host can't reach it via `docker -p 9222:9222`. This
|
||||
tiny TCP bridge (run inside the container) exposes the same CDP on all
|
||||
interfaces so the OmniRoute server's VNC harvester can connect from the host.
|
||||
|
||||
SECURITY (#12571): 9223 is reachable by any sibling container on the same
|
||||
Docker bridge network, not just the host, and CDP grants full control over a
|
||||
live, credential-bearing browser session (Runtime.evaluate, cookie theft,
|
||||
etc). Every connection MUST present the shared secret in CDP_BRIDGE_TOKEN
|
||||
(env, injected per-session by src/lib/vncSession/service.ts) as an
|
||||
`X-Omni-Cdp-Token: <token>` header on its first HTTP request/WS-upgrade
|
||||
before a single byte is forwarded upstream. A missing/invalid token gets the
|
||||
connection closed immediately with no response, so probing gives no signal.
|
||||
"""
|
||||
import socket, threading, sys
|
||||
import os, socket, threading, sys
|
||||
|
||||
SRC_HOST, SRC_PORT = "127.0.0.1", 9222
|
||||
PUB_HOST, PUB_PORT = "0.0.0.0", 9223
|
||||
TOKEN = os.environ.get("CDP_BRIDGE_TOKEN", "")
|
||||
TOKEN_HEADER = f"x-omni-cdp-token: {TOKEN}".lower()
|
||||
PEEK_TIMEOUT_S = 5
|
||||
MAX_PEEK_BYTES = 8192
|
||||
|
||||
|
||||
def has_valid_token(initial_chunk: bytes) -> bool:
|
||||
"""Check whether the client's first bytes carry the configured secret.
|
||||
|
||||
A missing/empty TOKEN always fails closed (no caller can present a valid
|
||||
empty header line the way this check is written).
|
||||
"""
|
||||
if not TOKEN:
|
||||
return False
|
||||
try:
|
||||
text = initial_chunk.decode("latin-1", errors="ignore").lower()
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
return False
|
||||
return TOKEN_HEADER in text
|
||||
|
||||
|
||||
def read_initial_chunk(client):
|
||||
client.settimeout(PEEK_TIMEOUT_S)
|
||||
try:
|
||||
return client.recv(MAX_PEEK_BYTES)
|
||||
except OSError:
|
||||
return b""
|
||||
finally:
|
||||
client.settimeout(None)
|
||||
|
||||
|
||||
def bridge(client, target_addr):
|
||||
initial = read_initial_chunk(client)
|
||||
if not has_valid_token(initial):
|
||||
client.close()
|
||||
return
|
||||
|
||||
try:
|
||||
upstream = socket.create_connection(target_addr, timeout=10)
|
||||
upstream.sendall(initial)
|
||||
except OSError:
|
||||
client.close()
|
||||
return
|
||||
|
||||
a = threading.Thread(target=pipe, args=(client, upstream), daemon=True)
|
||||
b = threading.Thread(target=pipe, args=(upstream, client), daemon=True)
|
||||
a.start(); b.start()
|
||||
|
||||
@@ -1452,6 +1452,7 @@ Containerized Chromium+VNC used for interactive browser-login credential capture
|
||||
| `OMNIROUTE_VNC_READY_MS` | `45000` | `src/lib/vncSession/manifest.ts` | Timeout (ms) waiting for the containerized browser to become CDP-ready. |
|
||||
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Timeout (ms) for harvesting the captured session/cookies after login completes. |
|
||||
| `OMNIROUTE_VNC_CHROMIUM_ARGS` | `--remote-debugging-port=9222 --no-first-run --no-default-browser-check` | `src/lib/vncSession/manifest.ts` | Extra command-line flags passed to the containerized Chromium. |
|
||||
| `OMNIROUTE_VNC_NETWORK` | `omniroute-vnc-browser-login` | `src/lib/vncSession/manifest.ts` | Dedicated Docker network the VNC login container joins (#12571) instead of the default bridge, so sibling containers can't reach its CDP bridge port. |
|
||||
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | **Legacy alias** for `DATA_DIR`, checked only after both `DATA_DIR` and `OMNIROUTE_DATA_DIR` are unset. Locates the Notion web-thread session cache (`<dir>/notion-web-thread-sessions.json`). |
|
||||
|
||||
---
|
||||
@@ -1590,6 +1591,7 @@ Used by `src/lib/vncSession/manifest.ts` to configure Docker-based headless Chro
|
||||
| `OMNIROUTE_VNC_MAX_SESSIONS` | `4` | `src/lib/vncSession/manifest.ts` | Maximum concurrent VNC sessions. |
|
||||
| `OMNIROUTE_VNC_READY_MS` | `45000` | `src/lib/vncSession/manifest.ts` | Browser readiness timeout (ms). |
|
||||
| `OMNIROUTE_VNC_HARVEST_MS` | `20000` | `src/lib/vncSession/manifest.ts` | Harvest/cleanup timeout (ms). |
|
||||
| `OMNIROUTE_VNC_NETWORK` | `omniroute-vnc-browser-login` | `src/lib/vncSession/manifest.ts` | Dedicated Docker network the container joins (#12571), off the default bridge. |
|
||||
| `VIBEPROXY_DATA_DIR` | _(unset)_ | `open-sse/services/notionThreadSessions.ts` | Directory for Notion thread session persistence. |
|
||||
|
||||
### Internal service auth
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
124
tests/unit/vnc-cdp-bridge-auth.test.ts
Normal file
124
tests/unit/vnc-cdp-bridge-auth.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import net from "node:net";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BRIDGE_SCRIPT = path.resolve(__dirname, "../../docker/vnc-browser/chromium/cdp-bridge.py");
|
||||
const UPSTREAM_PORT = 9222; // SRC_PORT in cdp-bridge.py
|
||||
const BRIDGE_PORT = 9223; // PUB_PORT in cdp-bridge.py
|
||||
const TOKEN = "test-secret-token-12571";
|
||||
|
||||
function waitForListening(server: net.Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("listening", () => resolve());
|
||||
server.once("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForBridgeReady(child: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("cdp-bridge.py did not report ready in time")),
|
||||
5000
|
||||
);
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
if (chunk.toString("utf8").includes("forwarding")) {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`cdp-bridge.py exited early with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startUpstream(): Promise<{ server: net.Server; receivedAnyBytes: () => boolean }> {
|
||||
let received = false;
|
||||
const server = net.createServer((socket) => {
|
||||
socket.on("data", () => {
|
||||
received = true;
|
||||
});
|
||||
});
|
||||
return waitForListening(server.listen(UPSTREAM_PORT, "127.0.0.1")).then(() => ({
|
||||
server,
|
||||
receivedAnyBytes: () => received,
|
||||
}));
|
||||
}
|
||||
|
||||
function startBridge(): ChildProcessWithoutNullStreams {
|
||||
return spawn("python3", [BRIDGE_SCRIPT], {
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: { ...process.env, CDP_BRIDGE_TOKEN: TOKEN },
|
||||
});
|
||||
}
|
||||
|
||||
test("cdp-bridge.py must not forward bytes from an unauthenticated peer (#12571)", async () => {
|
||||
const upstream = await startUpstream();
|
||||
const bridge = startBridge();
|
||||
|
||||
try {
|
||||
await waitForBridgeReady(bridge);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const client = net.createConnection({ host: "127.0.0.1", port: BRIDGE_PORT }, () => {
|
||||
client.write("GET /json/version HTTP/1.1\r\nHost: x\r\n\r\n");
|
||||
});
|
||||
client.once("error", reject);
|
||||
setTimeout(() => {
|
||||
client.destroy();
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
upstream.receivedAnyBytes(),
|
||||
false,
|
||||
"cdp-bridge.py forwarded traffic from an unauthenticated peer straight to Chromium's CDP " +
|
||||
"port — the bridge has no auth/token check (see docker/vnc-browser/chromium/cdp-bridge.py)"
|
||||
);
|
||||
} finally {
|
||||
bridge.kill("SIGKILL");
|
||||
await new Promise<void>((resolve) => upstream.server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("cdp-bridge.py forwards bytes once the caller presents the configured token (#12571)", async () => {
|
||||
const upstream = await startUpstream();
|
||||
const bridge = startBridge();
|
||||
|
||||
try {
|
||||
await waitForBridgeReady(bridge);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const client = net.createConnection({ host: "127.0.0.1", port: BRIDGE_PORT }, () => {
|
||||
client.write(
|
||||
`GET /json/version HTTP/1.1\r\nHost: x\r\nX-Omni-Cdp-Token: ${TOKEN}\r\n\r\n`
|
||||
);
|
||||
});
|
||||
client.once("error", reject);
|
||||
setTimeout(() => {
|
||||
client.destroy();
|
||||
resolve();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
upstream.receivedAnyBytes(),
|
||||
true,
|
||||
"cdp-bridge.py should forward traffic once the caller presents the correct " +
|
||||
"CDP_BRIDGE_TOKEN"
|
||||
);
|
||||
} finally {
|
||||
bridge.kill("SIGKILL");
|
||||
await new Promise<void>((resolve) => upstream.server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
46
tests/unit/vnc-session-docker-args.test.ts
Normal file
46
tests/unit/vnc-session-docker-args.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildRunArgs, sessionKey } from "@/lib/vncSession/service";
|
||||
import { VNC_CONFIG } from "@/lib/vncSession/manifest";
|
||||
|
||||
test("buildRunArgs (#12571) injects the CDP bridge token and a non-default network", () => {
|
||||
const args = buildRunArgs({
|
||||
containerName: sessionKey("session-abc"),
|
||||
sessionId: "session-abc",
|
||||
connectionId: "connection-xyz",
|
||||
profileDir: "/tmp/profile",
|
||||
chromeCli: "--remote-debugging-port=9222 https://example.com",
|
||||
cdpToken: "super-secret-token",
|
||||
});
|
||||
|
||||
const networkIndex = args.indexOf("--network");
|
||||
assert.ok(networkIndex >= 0, "docker run args must include --network");
|
||||
assert.equal(args[networkIndex + 1], VNC_CONFIG.network);
|
||||
assert.notEqual(
|
||||
args[networkIndex + 1],
|
||||
"bridge",
|
||||
"must not join Docker's default bridge network (#12571)"
|
||||
);
|
||||
|
||||
const envFlags = args.filter((_value, index) => args[index - 1] === "-e");
|
||||
assert.ok(
|
||||
envFlags.some((flag) => flag === "CDP_BRIDGE_TOKEN=super-secret-token"),
|
||||
"docker run args must inject CDP_BRIDGE_TOKEN for the container's cdp-bridge.py"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildRunArgs (#12571) generates a distinct token per call so sessions cannot reuse each other's secret", () => {
|
||||
const base = {
|
||||
containerName: "c",
|
||||
sessionId: "s",
|
||||
connectionId: "conn",
|
||||
profileDir: "/tmp/p",
|
||||
chromeCli: "--x",
|
||||
};
|
||||
const argsA = buildRunArgs({ ...base, cdpToken: "token-a" });
|
||||
const argsB = buildRunArgs({ ...base, cdpToken: "token-b" });
|
||||
|
||||
assert.ok(argsA.includes("CDP_BRIDGE_TOKEN=token-a"));
|
||||
assert.ok(argsB.includes("CDP_BRIDGE_TOKEN=token-b"));
|
||||
assert.notDeepEqual(argsA, argsB);
|
||||
});
|
||||
Reference in New Issue
Block a user