22export const renderPlaywrightBrowserDockerfile = ( ) : string =>
33 `FROM kechangdev/browser-vnc:latest
44
5- # bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP proxy
6- # python3/net-tools for diagnostics
7- RUN apk add --no-cache bash procps socat python3 net-tools
5+ # bash for noVNC startup, procps for ps -p used by novnc_proxy, socat for CDP fallback
6+ # nodejs/npm/ws for the CDP guard, python3/net-tools for diagnostics
7+ RUN apk add --no-cache bash procps socat nodejs npm python3 net-tools
8+ RUN npm install --omit=dev --prefix /opt/docker-git-cdp-guard ws@8.18.3
9+
10+ RUN cat <<'EOF' > /usr/local/bin/docker-git-cdp-guard
11+ ${ cdpGuardScript }
12+ EOF
13+ RUN chmod +x /usr/local/bin/docker-git-cdp-guard
814
915COPY mcp-playwright-start-extra.sh /usr/local/bin/mcp-playwright-start-extra.sh
1016RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh
@@ -13,6 +19,231 @@ RUN chmod +x /usr/local/bin/mcp-playwright-start-extra.sh
1319# Clear stale Chromium profile locks before boot
1420ENTRYPOINT ["/bin/sh", "-lc", "rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true; /usr/local/bin/mcp-playwright-start-extra.sh & exec /start.sh"]`
1521
22+ const cdpGuardScript = String . raw `#!/usr/bin/env node
23+ "use strict";
24+
25+ const http = require("node:http");
26+ const { URL } = require("node:url");
27+ const { WebSocket, WebSocketServer } = require("/opt/docker-git-cdp-guard/node_modules/ws");
28+
29+ const upstreamHost = process.env.MCP_PLAYWRIGHT_UPSTREAM_CDP_HOST || "127.0.0.1";
30+ const upstreamPort = Number.parseInt(process.env.MCP_PLAYWRIGHT_UPSTREAM_CDP_PORT || "9222", 10);
31+ const listenHost = process.env.MCP_PLAYWRIGHT_CDP_GUARD_HOST || "0.0.0.0";
32+ const listenPort = Number.parseInt(process.env.MCP_PLAYWRIGHT_CDP_GUARD_PORT || "9223", 10);
33+ const blockedMethods = new Set(["Browser.close", "Browser.crash", "Browser.crashGpuProcess"]);
34+
35+ const log = (message) => process.stderr.write("[docker-git-cdp-guard] " + message + "\n");
36+
37+ const shouldBlockBrowserClose = () => process.env.MCP_PLAYWRIGHT_BLOCK_BROWSER_CLOSE !== "0";
38+
39+ const requestHost = (request) => {
40+ const host = request.headers.host;
41+ return typeof host === "string" && host.length > 0 ? host : "127.0.0.1:" + listenPort;
42+ };
43+
44+ const rewriteWebSocketUrl = (value, host) => {
45+ try {
46+ const url = new URL(value);
47+ url.protocol = "ws:";
48+ url.host = host;
49+ return url.toString();
50+ } catch {
51+ return value;
52+ }
53+ };
54+
55+ const rewriteDebuggerUrls = (value, host) => {
56+ if (Array.isArray(value)) {
57+ return value.map((item) => rewriteDebuggerUrls(item, host));
58+ }
59+ if (value === null || typeof value !== "object") {
60+ return value;
61+ }
62+ return Object.fromEntries(
63+ Object.entries(value).map(([key, child]) => [
64+ key,
65+ key === "webSocketDebuggerUrl" && typeof child === "string"
66+ ? rewriteWebSocketUrl(child, host)
67+ : rewriteDebuggerUrls(child, host)
68+ ])
69+ );
70+ };
71+
72+ const rewriteJsonBody = (body, host) => {
73+ try {
74+ return Buffer.from(JSON.stringify(rewriteDebuggerUrls(JSON.parse(body.toString("utf8")), host)));
75+ } catch {
76+ return body;
77+ }
78+ };
79+
80+ const proxyHttp = (request, response) => {
81+ const chunks = [];
82+ request.on("data", (chunk) => chunks.push(chunk));
83+ request.on("end", () => {
84+ const headers = { ...request.headers, host: upstreamHost + ":" + upstreamPort };
85+ delete headers.connection;
86+ delete headers["content-length"];
87+ const upstream = http.request(
88+ {
89+ hostname: upstreamHost,
90+ port: upstreamPort,
91+ method: request.method,
92+ path: request.url || "/",
93+ headers
94+ },
95+ (upstreamResponse) => {
96+ const upstreamChunks = [];
97+ upstreamResponse.on("data", (chunk) => upstreamChunks.push(chunk));
98+ upstreamResponse.on("end", () => {
99+ const rawBody = Buffer.concat(upstreamChunks);
100+ const body = (request.url || "/").startsWith("/json")
101+ ? rewriteJsonBody(rawBody, requestHost(request))
102+ : rawBody;
103+ const responseHeaders = { ...upstreamResponse.headers };
104+ delete responseHeaders["content-length"];
105+ delete responseHeaders["content-encoding"];
106+ response.writeHead(upstreamResponse.statusCode || 502, responseHeaders);
107+ response.end(body);
108+ });
109+ }
110+ );
111+ upstream.on("error", (error) => {
112+ response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
113+ response.end("CDP upstream unavailable: " + error.message + "\n");
114+ });
115+ upstream.end(Buffer.concat(chunks));
116+ });
117+ };
118+
119+ const fetchCurrentBrowserPath = () =>
120+ new Promise((resolve, reject) => {
121+ const request = http.get(
122+ {
123+ hostname: upstreamHost,
124+ port: upstreamPort,
125+ path: "/json/version",
126+ headers: { host: upstreamHost + ":" + upstreamPort }
127+ },
128+ (response) => {
129+ const chunks = [];
130+ response.on("data", (chunk) => chunks.push(chunk));
131+ response.on("end", () => {
132+ try {
133+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
134+ const raw = typeof parsed.webSocketDebuggerUrl === "string" ? parsed.webSocketDebuggerUrl : "";
135+ const path = raw.length > 0 ? new URL(raw).pathname : "";
136+ path.length > 0 ? resolve(path) : reject(new Error("webSocketDebuggerUrl missing"));
137+ } catch (error) {
138+ reject(error);
139+ }
140+ });
141+ }
142+ );
143+ request.on("error", reject);
144+ });
145+
146+ const upstreamPathFor = async (rawPath) => {
147+ const path = rawPath || "/";
148+ return path.startsWith("/devtools/browser/") ? await fetchCurrentBrowserPath() : path;
149+ };
150+
151+ const parseMessage = (data) => JSON.parse(Buffer.isBuffer(data) ? data.toString("utf8") : String(data));
152+
153+ const isBlockedCdpMessage = (data) => {
154+ if (!shouldBlockBrowserClose()) {
155+ return false;
156+ }
157+ try {
158+ const message = parseMessage(data);
159+ return message !== null && typeof message === "object" && blockedMethods.has(message.method);
160+ } catch {
161+ return false;
162+ }
163+ };
164+
165+ const blockedCdpResponse = (data) => {
166+ try {
167+ const message = parseMessage(data);
168+ return Object.prototype.hasOwnProperty.call(message, "id")
169+ ? JSON.stringify({ id: message.id, result: {} })
170+ : "";
171+ } catch {
172+ return "";
173+ }
174+ };
175+
176+ const handleWebSocket = async (client, request) => {
177+ const pending = [];
178+ let upstream = null;
179+ const forwardToUpstream = (data, isBinary) => {
180+ if (!upstream || upstream.readyState !== WebSocket.OPEN) {
181+ pending.push([data, isBinary]);
182+ return;
183+ }
184+ if (!isBinary && isBlockedCdpMessage(data)) {
185+ const response = blockedCdpResponse(data);
186+ if (response.length > 0 && client.readyState === WebSocket.OPEN) {
187+ client.send(response);
188+ }
189+ return;
190+ }
191+ upstream.send(data, { binary: isBinary });
192+ };
193+
194+ client.on("message", forwardToUpstream);
195+
196+ try {
197+ const upstreamPath = await upstreamPathFor(request.url || "/");
198+ upstream = new WebSocket("ws://" + upstreamHost + ":" + upstreamPort + upstreamPath, {
199+ headers: { host: upstreamHost + ":" + upstreamPort }
200+ });
201+ upstream.on("open", () => {
202+ for (const [data, isBinary] of pending.splice(0)) {
203+ forwardToUpstream(data, isBinary);
204+ }
205+ });
206+ upstream.on("message", (data, isBinary) => {
207+ if (client.readyState === WebSocket.OPEN) {
208+ client.send(data, { binary: isBinary });
209+ }
210+ });
211+ upstream.on("close", (code, reason) => {
212+ if (client.readyState === WebSocket.OPEN) {
213+ client.close(code, reason);
214+ }
215+ });
216+ upstream.on("error", (error) => {
217+ log("upstream websocket error: " + error.message);
218+ if (client.readyState === WebSocket.OPEN) {
219+ client.close(1011, "CDP upstream websocket error");
220+ }
221+ });
222+ client.on("close", () => {
223+ if (upstream && upstream.readyState === WebSocket.OPEN) {
224+ upstream.close();
225+ }
226+ });
227+ } catch (error) {
228+ log("websocket setup failed: " + error.message);
229+ client.close(1011, "CDP upstream unavailable");
230+ }
231+ };
232+
233+ const server = http.createServer(proxyHttp);
234+ const wss = new WebSocketServer({ noServer: true });
235+
236+ server.on("upgrade", (request, socket, head) => {
237+ wss.handleUpgrade(request, socket, head, (client) => {
238+ handleWebSocket(client, request);
239+ });
240+ });
241+
242+ server.listen(listenPort, listenHost, () => {
243+ log("listening on " + listenHost + ":" + listenPort + " -> " + upstreamHost + ":" + upstreamPort);
244+ });
245+ `
246+
16247export const renderPlaywrightStartExtra = ( ) : string =>
17248 `#!/bin/sh
18249set -eu
@@ -23,8 +254,12 @@ rm -f /data/SingletonLock /data/SingletonCookie /data/SingletonSocket || true
23254# Wait for chromium/x11vnc/noVNC to come up
24255sleep 2
25256
26- # CDP proxy: expose 9223 on the docker network, forward to 127.0.0.1:9222 inside the browser container
27- socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 &
257+ # CDP guard: expose 9223 on the docker network and block browser-level destructive CDP methods
258+ if [ "\${MCP_PLAYWRIGHT_CDP_GUARD:-1}" = "1" ]; then
259+ docker-git-cdp-guard >/var/log/docker-git-cdp-guard.log 2>&1 &
260+ else
261+ socat TCP-LISTEN:9223,fork,reuseaddr TCP:127.0.0.1:9222 >/var/log/socat-9223.log 2>&1 &
262+ fi
28263
29264# Optional VNC password disabling (useful if you publish VNC/noVNC ports)
30265if [ "\${VNC_NOPW:-1}" = "1" ]; then
0 commit comments