From 2d8f1f333a71cf4b3cc491b6ca16716e9294ef7e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 3 Aug 2026 14:53:46 -0700 Subject: [PATCH 1/7] fix(mcp): validate Host and Origin on CDP relay WebSocket upgrades The relay HTTP server only binds to localhost, but the WebSocket upgrade accepted any Host header (DNS rebinding) and any Origin. Reject upgrades with a non-loopback Host or a web page Origin; the legitimate clients are the local Playwright client (no Origin) and the extension (chrome-extension:// Origin). Fixes: https://github.com/microsoft/playwright-mcp/issues/1694 --- .../playwright-core/src/tools/mcp/cdpRelay.ts | 24 +++++++++- tests/extension/extension.spec.ts | 47 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 2de9348bf62b7..1c24fa93a4a77 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -32,6 +32,7 @@ import os from 'os'; import debug from 'debug'; import ws, { WebSocketServer as wsServer } from 'ws'; import { ManualPromise } from '@isomorphic/manualPromise'; +import { hostnameFromHostHeader } from '@utils/httpServer'; import { registry } from '../../server/registry/index'; import { findPlaywrightExtensionProfile, playwrightExtensionId } from '../utils/extension'; @@ -48,6 +49,8 @@ import type { WebSocket, WebSocketServer } from 'ws'; const debugLogger = debug('pw:mcp:relay'); +const kLoopbackHostnames = new Set(['localhost', '127.0.0.1', '[::1]']); + type CDPCommand = { id: number; sessionId?: string; @@ -92,7 +95,7 @@ export class CDPRelayServer { this._extensionPath = `/extension/${uuid}`; void this._extensionConnectionPromise.catch(logUnhandledError); - this._wss = new wsServer({ server }); + this._wss = new wsServer({ server, verifyClient: this._verifyClient.bind(this) }); this._wss.on('connection', this._onConnection.bind(this)); } @@ -170,6 +173,25 @@ export class CDPRelayServer { this._closeExtensionConnection(reason); } + // The relay server binds to localhost only, and its legitimate clients are the + // local Playwright client (no Origin header) and the extension + // (chrome-extension:// Origin). Reject upgrades with a non-loopback Host + // (DNS rebinding) or a web page Origin — WebSocket connections are not + // restricted by the same-origin policy, so any page could dial the relay directly. + private _verifyClient(info: { origin?: string, req: http.IncomingMessage }): boolean { + const host = info.req.headers.host; + const hostname = host ? hostnameFromHostHeader(host.toLowerCase()) : undefined; + if (!hostname || !kLoopbackHostnames.has(hostname)) { + debugLogger(`Rejected WebSocket upgrade with Host: ${host}`); + return false; + } + if (info.origin && /^https?:/i.test(info.origin)) { + debugLogger(`Rejected WebSocket upgrade with Origin: ${info.origin}`); + return false; + } + return true; + } + private _onConnection(ws: WebSocket, request: http.IncomingMessage): void { const url = new URL(`http://localhost${request.url}`); debugLogger(`New connection to ${url.pathname}`); diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index 17574346072f0..e1ad957039b19 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -17,6 +17,8 @@ import fs from 'fs/promises'; import path from 'path'; +import WebSocket from 'ws'; + import { test, testWithOldExtensionVersion, expect, extensionId, clickAllowAndSelect, connectAndNavigate, readExtensionToken, startWithExtensionFlag } from './extension-fixtures'; import { utils } from '../../packages/playwright-core/lib/coreBundle'; @@ -405,3 +407,48 @@ test(`reconnects after the extension connection drops`, { snapshot: expect.stringContaining(`Hello, world!`), }); }); + +test(`relay rejects websocket upgrades with forged host or origin`, { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/1694' }, +}, async ({ startExtensionClient, server }) => { + const { browserContext, client } = await startExtensionClient(); + + const confirmationPagePromise = browserContext.waitForEvent('page', page => { + return page.url().startsWith(`chrome-extension://${extensionId}/connect.html`); + }); + const navigateResponse = client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + }); + const connectPage = await confirmationPagePromise; + const relayUrl = new URL(connectPage.url()).searchParams.get('mcpRelayUrl')!; + expect(relayUrl).toBeTruthy(); + await clickAllowAndSelect(connectPage, 'Welcome'); + await navigateResponse; + + // Non-loopback Host (DNS rebinding) and web page Origin must be rejected + // during the upgrade. + expect(await wsUpgradeResult(relayUrl, { host: 'evil.com' })).toBe(401); + expect(await wsUpgradeResult(relayUrl, { host: 'evil.com:80' })).toBe(401); + expect(await wsUpgradeResult(relayUrl, { origin: 'http://evil.com' })).toBe(401); + expect(await wsUpgradeResult(relayUrl, { origin: 'https://evil.com' })).toBe(401); + + // Control: default headers pass the upgrade validation; the connection is + // then closed only because the extension is already connected. + expect(await wsUpgradeResult(relayUrl)).toBe('connected'); +}); + +function wsUpgradeResult(url: string, headers?: Record): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { headers }); + ws.on('open', () => { + ws.close(); + resolve('connected'); + }); + ws.on('unexpected-response', (request, response) => { + request.destroy(); + resolve(response.statusCode!); + }); + ws.on('error', reject); + }); +} From aa7dc12ff8c7bc06096579249f6e4e8402253765 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 3 Aug 2026 14:54:23 -0700 Subject: [PATCH 2/7] chore(mcp): trim comments in relay upgrade validation --- packages/playwright-core/src/tools/mcp/cdpRelay.ts | 7 ++----- tests/extension/extension.spec.ts | 5 +---- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 1c24fa93a4a77..164a1151c1f3a 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -173,11 +173,8 @@ export class CDPRelayServer { this._closeExtensionConnection(reason); } - // The relay server binds to localhost only, and its legitimate clients are the - // local Playwright client (no Origin header) and the extension - // (chrome-extension:// Origin). Reject upgrades with a non-loopback Host - // (DNS rebinding) or a web page Origin — WebSocket connections are not - // restricted by the same-origin policy, so any page could dial the relay directly. + // Reject upgrades with a non-loopback Host (DNS rebinding) or a web page + // Origin — legitimate clients are local and don't send an http(s) Origin. private _verifyClient(info: { origin?: string, req: http.IncomingMessage }): boolean { const host = info.req.headers.host; const hostname = host ? hostnameFromHostHeader(host.toLowerCase()) : undefined; diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index e1ad957039b19..ab1a2371ea66a 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -426,15 +426,12 @@ test(`relay rejects websocket upgrades with forged host or origin`, { await clickAllowAndSelect(connectPage, 'Welcome'); await navigateResponse; - // Non-loopback Host (DNS rebinding) and web page Origin must be rejected - // during the upgrade. expect(await wsUpgradeResult(relayUrl, { host: 'evil.com' })).toBe(401); expect(await wsUpgradeResult(relayUrl, { host: 'evil.com:80' })).toBe(401); expect(await wsUpgradeResult(relayUrl, { origin: 'http://evil.com' })).toBe(401); expect(await wsUpgradeResult(relayUrl, { origin: 'https://evil.com' })).toBe(401); - // Control: default headers pass the upgrade validation; the connection is - // then closed only because the extension is already connected. + // Control: default headers pass the upgrade validation. expect(await wsUpgradeResult(relayUrl)).toBe('connected'); }); From efe951c749782789eaba1a6e3297c192341ea93a Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 3 Aug 2026 15:06:58 -0700 Subject: [PATCH 3/7] fix: validate Host on HttpServer WebSocket upgrades, reuse in CDP relay Extract the Host allowlist check from HttpServer._onRequest into isAllowedHost(), enforce it on createWebSocket() upgrades, and reuse it together with the shared loopback host set in the CDP relay. --- .../playwright-core/src/tools/mcp/cdpRelay.ts | 10 ++---- packages/utils/httpServer.ts | 31 +++++++++++++------ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 164a1151c1f3a..e8e035bc74cfc 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -32,7 +32,7 @@ import os from 'os'; import debug from 'debug'; import ws, { WebSocketServer as wsServer } from 'ws'; import { ManualPromise } from '@isomorphic/manualPromise'; -import { hostnameFromHostHeader } from '@utils/httpServer'; +import { isAllowedHost, kLoopbackHosts } from '@utils/httpServer'; import { registry } from '../../server/registry/index'; import { findPlaywrightExtensionProfile, playwrightExtensionId } from '../utils/extension'; @@ -49,8 +49,6 @@ import type { WebSocket, WebSocketServer } from 'ws'; const debugLogger = debug('pw:mcp:relay'); -const kLoopbackHostnames = new Set(['localhost', '127.0.0.1', '[::1]']); - type CDPCommand = { id: number; sessionId?: string; @@ -176,10 +174,8 @@ export class CDPRelayServer { // Reject upgrades with a non-loopback Host (DNS rebinding) or a web page // Origin — legitimate clients are local and don't send an http(s) Origin. private _verifyClient(info: { origin?: string, req: http.IncomingMessage }): boolean { - const host = info.req.headers.host; - const hostname = host ? hostnameFromHostHeader(host.toLowerCase()) : undefined; - if (!hostname || !kLoopbackHostnames.has(hostname)) { - debugLogger(`Rejected WebSocket upgrade with Host: ${host}`); + if (!isAllowedHost(info.req, kLoopbackHosts)) { + debugLogger(`Rejected WebSocket upgrade with Host: ${info.req.headers.host}`); return false; } if (info.origin && /^https?:/i.test(info.origin)) { diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index 4e36467248c14..ecfac178c0e19 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -89,6 +89,11 @@ export class HttpServer { const pathname = new URL(request.url ?? '/', 'http://localhost').pathname; if (pathname !== wsPath) return; + if (!isAllowedHost(request, this._allowedHosts)) { + socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.destroy(); + return; + } wss.handleUpgrade(request, socket, head, ws => wss.emit('connection', ws, request)); }); // HMR end @@ -268,14 +273,10 @@ export class HttpServer { return; } - if (this._allowedHosts) { - const host = request.headers.host?.toLowerCase(); - const hostname = host ? hostnameFromHostHeader(host) : undefined; - if (!hostname || !this._allowedHosts.has(hostname)) { - response.statusCode = 403; - response.end(); - return; - } + if (!isAllowedHost(request, this._allowedHosts)) { + response.statusCode = 403; + response.end(); + return; } request.on('error', () => response.end()); @@ -299,6 +300,9 @@ export class HttpServer { } } +// Loopback hosts in the Host header form. +export const kLoopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]']); + export function computeAllowedHosts(requested: string | undefined, bound: string): Set | null { const loopback = new Set(['127.0.0.1', '::1', 'localhost']); const isLoopback = (h: string | undefined) => h !== undefined && loopback.has(h.toLowerCase()); @@ -306,7 +310,16 @@ export function computeAllowedHosts(requested: string | undefined, bound: string return null; if (!isLoopback(bound) && requested === undefined) return null; - return new Set(['localhost', '127.0.0.1', '[::1]']); + return new Set(kLoopbackHosts); +} + +// A null allowlist disables the check (server deliberately bound to a public address). +export function isAllowedHost(request: http.IncomingMessage, allowedHosts: Set | null): boolean { + if (!allowedHosts) + return true; + const host = request.headers.host?.toLowerCase(); + const hostname = host ? hostnameFromHostHeader(host) : undefined; + return !!hostname && allowedHosts.has(hostname); } // Bracket IPv6 literals so they can be used as the host part of a URL. From 45519df32ce37ef42b212474b3cb99f2dd5f36bf Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 3 Aug 2026 15:47:39 -0700 Subject: [PATCH 4/7] fix(mcp): use WSServer in CDP relay for host and origin validation Replace the raw ws server and custom verifyClient in CDPRelayServer with WSServer, which now validates the Host allowlist on upgrades in addition to Origin. Restrict the WSServer origin check to http(s) origins so that extension connections are allowed. --- .../playwright-core/src/tools/mcp/cdpRelay.ts | 70 ++++++++----------- .../src/tools/mcp/extensionContextFactory.ts | 6 +- packages/utils/httpServer.ts | 7 +- packages/utils/wsServer.ts | 38 ++++++---- tests/extension/extension.spec.ts | 8 +-- 5 files changed, 61 insertions(+), 68 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index e8e035bc74cfc..6be57ad5555d9 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -26,17 +26,15 @@ */ import { spawn } from 'child_process'; -import http from 'http'; import os from 'os'; import debug from 'debug'; -import ws, { WebSocketServer as wsServer } from 'ws'; +import ws from 'ws'; import { ManualPromise } from '@isomorphic/manualPromise'; -import { isAllowedHost, kLoopbackHosts } from '@utils/httpServer'; +import { WSServer } from '@utils/wsServer'; import { registry } from '../../server/registry/index'; import { findPlaywrightExtensionProfile, playwrightExtensionId } from '../utils/extension'; -import { addressToString } from '../utils/mcp/http'; import { logUnhandledError } from './log'; import { ExtensionProtocolV2 } from './cdpRelayV2'; import * as protocol from './protocol'; @@ -44,7 +42,7 @@ import * as protocol from './protocol'; import type websocket from 'ws'; import type { ExtensionCommandV2, ExtensionEventsV2 } from './protocol'; import type { CDPMessage } from './browserModel'; -import type { WebSocket, WebSocketServer } from 'ws'; +import type { WebSocket } from 'ws'; const debugLogger = debug('pw:mcp:relay'); @@ -59,23 +57,20 @@ type CDPCommand = { type CDPResponse = CDPMessage; export class CDPRelayServer { - private _httpServer: http.Server; - private _wsHost: string; + private _wsServer: WSServer; + private _wsHost!: string; private _browserChannel: string; private _executablePath?: string; private _userDataDir?: string; private _cdpPath: string; private _extensionPath: string; - private _wss: WebSocketServer; private _cdpConnection: WebSocket | null = null; private _extensionConnection: ExtensionConnection | null = null; private _protocolVersion: number; private _handler: ExtensionProtocolV2; private _extensionConnectionPromise = new ManualPromise(); - constructor(server: http.Server, browserChannel: string, executablePath?: string, userDataDir?: string) { - this._httpServer = server; - this._wsHost = addressToString(server.address(), { protocol: 'ws' }); + constructor(browserChannel: string, executablePath?: string, userDataDir?: string) { this._browserChannel = browserChannel; this._executablePath = executablePath; this._userDataDir = userDataDir; @@ -93,8 +88,27 @@ export class CDPRelayServer { this._extensionPath = `/extension/${uuid}`; void this._extensionConnectionPromise.catch(logUnhandledError); - this._wss = new wsServer({ server, verifyClient: this._verifyClient.bind(this) }); - this._wss.on('connection', this._onConnection.bind(this)); + this._wsServer = new WSServer({ + onRequest: (request, response) => { + response.statusCode = 404; + response.end(); + }, + onHeaders: () => {}, + onUpgrade: () => undefined, + isValidPathname: pathname => pathname === this._cdpPath || pathname === this._extensionPath, + onConnection: (request, url, ws) => { + debugLogger(`New connection to ${url.pathname}`); + if (url.pathname === this._cdpPath) + this._handlePlaywrightConnection(ws); + else + this._handleExtensionConnection(ws); + return { close: async () => {} }; + }, + }); + } + + async start(): Promise { + this._wsHost = await this._wsServer.listen(0, undefined, ''); } cdpEndpoint() { @@ -162,8 +176,7 @@ export class CDPRelayServer { stop(): void { this._closeConnections('Server stopped'); - this._wss.close(); - this._httpServer.close(); + void this._wsServer.close().catch(logUnhandledError); } private _closeConnections(reason: string) { @@ -171,33 +184,6 @@ export class CDPRelayServer { this._closeExtensionConnection(reason); } - // Reject upgrades with a non-loopback Host (DNS rebinding) or a web page - // Origin — legitimate clients are local and don't send an http(s) Origin. - private _verifyClient(info: { origin?: string, req: http.IncomingMessage }): boolean { - if (!isAllowedHost(info.req, kLoopbackHosts)) { - debugLogger(`Rejected WebSocket upgrade with Host: ${info.req.headers.host}`); - return false; - } - if (info.origin && /^https?:/i.test(info.origin)) { - debugLogger(`Rejected WebSocket upgrade with Origin: ${info.origin}`); - return false; - } - return true; - } - - private _onConnection(ws: WebSocket, request: http.IncomingMessage): void { - const url = new URL(`http://localhost${request.url}`); - debugLogger(`New connection to ${url.pathname}`); - if (url.pathname === this._cdpPath) { - this._handlePlaywrightConnection(ws); - } else if (url.pathname === this._extensionPath) { - this._handleExtensionConnection(ws); - } else { - debugLogger(`Invalid path: ${url.pathname}`); - ws.close(4004, 'Invalid path'); - } - } - private _handlePlaywrightConnection(ws: WebSocket): void { if (!this._extensionConnection) { debugLogger('Rejecting Playwright connection: extension not connected'); diff --git a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts index 8c3f6f3e8bac7..c8c8d1d8cca6e 100644 --- a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts +++ b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts @@ -15,7 +15,6 @@ */ import debug from 'debug'; -import { createHttpServer, startHttpServer } from '@utils/network'; import { defaultUserDataDirForChannel } from '@utils/chromiumChannels'; import { playwright } from '../../inprocess'; import { isPlaywrightExtensionInstalled, playwrightExtensionInstallUrl } from '../utils/extension'; @@ -34,9 +33,8 @@ export async function createExtensionBrowser(channel: string, executablePath: st throw new Error(`Playwright Extension not found in "${userDataDir}". Install it from ${playwrightExtensionInstallUrl}`); } - const httpServer = createHttpServer(); - await startHttpServer(httpServer, {}); - const relay = new CDPRelayServer(httpServer, channel, executablePath, userDataDir); + const relay = new CDPRelayServer(channel, executablePath, userDataDir); + await relay.start(); debugLogger(`CDP relay server started, extension endpoint: ${relay.extensionEndpoint()}.`); try { diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index ecfac178c0e19..b1bf75c885858 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -300,9 +300,6 @@ export class HttpServer { } } -// Loopback hosts in the Host header form. -export const kLoopbackHosts = new Set(['localhost', '127.0.0.1', '[::1]']); - export function computeAllowedHosts(requested: string | undefined, bound: string): Set | null { const loopback = new Set(['127.0.0.1', '::1', 'localhost']); const isLoopback = (h: string | undefined) => h !== undefined && loopback.has(h.toLowerCase()); @@ -310,11 +307,11 @@ export function computeAllowedHosts(requested: string | undefined, bound: string return null; if (!isLoopback(bound) && requested === undefined) return null; - return new Set(kLoopbackHosts); + return new Set(['localhost', '127.0.0.1', '[::1]']); } // A null allowlist disables the check (server deliberately bound to a public address). -export function isAllowedHost(request: http.IncomingMessage, allowedHosts: Set | null): boolean { +function isAllowedHost(request: http.IncomingMessage, allowedHosts: Set | null): boolean { if (!allowedHosts) return true; const host = request.headers.host?.toLowerCase(); diff --git a/packages/utils/wsServer.ts b/packages/utils/wsServer.ts index 93625ecb60ed0..84880ac89aa05 100644 --- a/packages/utils/wsServer.ts +++ b/packages/utils/wsServer.ts @@ -47,6 +47,8 @@ export type WSServerDelegate = { onHeaders: (headers: string[]) => void; onUpgrade: (request: http.IncomingMessage, socket: stream.Duplex) => { error: string } | undefined; onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection; + // Overrides the default `pathname === path` check on upgrade requests. + isValidPathname?: (pathname: string) => boolean; }; export class WSServer { @@ -101,12 +103,13 @@ export class WSServer { server.on('upgrade', (request, socket, head) => { const pathname = new URL('http://localhost' + request.url!).pathname; - if (pathname !== path) { + const isValidPathname = this._delegate.isValidPathname ?? (pathname => pathname === path); + if (!isValidPathname(pathname)) { socket.write(`HTTP/${request.httpVersion} 400 Bad Request\r\n\r\n`); socket.destroy(); return; } - if (this._allowedHosts && !this._isAllowedOrigin(request.headers.origin)) { + if (!this._isAllowedHost(request) || !this._isAllowedOrigin(request.headers.origin)) { socket.write(`HTTP/${request.httpVersion} 403 Forbidden\r\n\r\n`); socket.destroy(); return; @@ -133,25 +136,34 @@ export class WSServer { } private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { - if (this._allowedHosts) { - const host = request.headers.host?.toLowerCase(); - const hostname = host ? hostnameFromHostHeader(host) : undefined; - if (!hostname || !this._allowedHosts.has(hostname)) { - response.statusCode = 403; - response.end(); - return; - } + if (!this._isAllowedHost(request)) { + response.statusCode = 403; + response.end(); + return; } this._delegate.onRequest(request, response); } + private _isAllowedHost(request: http.IncomingMessage): boolean { + if (!this._allowedHosts) + return true; + const host = request.headers.host?.toLowerCase(); + const hostname = host ? hostnameFromHostHeader(host) : undefined; + return !!hostname && this._allowedHosts.has(hostname); + } + private _isAllowedOrigin(origin: string | undefined): boolean { - if (!origin) + if (!this._allowedHosts || !origin) return true; try { - const hostname = new URL(origin).hostname.toLowerCase(); + const url = new URL(origin); + // Only web page origins are subject to the check; e.g. browser + // extensions are allowed. + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return true; + const hostname = url.hostname.toLowerCase(); const bracketed = hostname.includes(':') ? `[${hostname}]` : hostname; - return this._allowedHosts!.has(hostname) || this._allowedHosts!.has(bracketed); + return this._allowedHosts.has(hostname) || this._allowedHosts.has(bracketed); } catch { return false; } diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index ab1a2371ea66a..9945ca5a74106 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -426,10 +426,10 @@ test(`relay rejects websocket upgrades with forged host or origin`, { await clickAllowAndSelect(connectPage, 'Welcome'); await navigateResponse; - expect(await wsUpgradeResult(relayUrl, { host: 'evil.com' })).toBe(401); - expect(await wsUpgradeResult(relayUrl, { host: 'evil.com:80' })).toBe(401); - expect(await wsUpgradeResult(relayUrl, { origin: 'http://evil.com' })).toBe(401); - expect(await wsUpgradeResult(relayUrl, { origin: 'https://evil.com' })).toBe(401); + expect(await wsUpgradeResult(relayUrl, { host: 'evil.com' })).toBe(403); + expect(await wsUpgradeResult(relayUrl, { host: 'evil.com:80' })).toBe(403); + expect(await wsUpgradeResult(relayUrl, { origin: 'http://evil.com' })).toBe(403); + expect(await wsUpgradeResult(relayUrl, { origin: 'https://evil.com' })).toBe(403); // Control: default headers pass the upgrade validation. expect(await wsUpgradeResult(relayUrl)).toBe('connected'); From b612cdbf96fda2d788e9c7d9b4a8c1b8bdc23aee Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 3 Aug 2026 16:34:45 -0700 Subject: [PATCH 5/7] chore: make isAllowedPathname a required WSServer delegate method Rename isValidPathname to isAllowedPathname, require it in the delegate and define it at all callsites. Drop the unused path parameter of the PlaywrightWebSocketServer constructor. --- packages/playwright-core/src/remote/playwrightServer.ts | 2 ++ .../src/remote/playwrightWebSocketServer.ts | 7 +++++-- packages/playwright-core/src/server/browser.ts | 2 +- packages/playwright-core/src/tools/mcp/cdpRelay.ts | 2 +- packages/utils/wsServer.ts | 6 ++---- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 960a4a6e278a0..c1928ce1e6b5e 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -84,6 +84,8 @@ export class PlaywrightServer { return { error: `HTTP/${request.httpVersion} 428 Precondition Required\r\n\r\n${uaError}` }; }, + isAllowedPathname: pathname => pathname === this._options.path, + onHeaders: headers => { if (process.env.PWTEST_SERVER_WS_HEADERS) headers.push(process.env.PWTEST_SERVER_WS_HEADERS!); diff --git a/packages/playwright-core/src/remote/playwrightWebSocketServer.ts b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts index 11e9c3786dc8d..44abafd007323 100644 --- a/packages/playwright-core/src/remote/playwrightWebSocketServer.ts +++ b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts @@ -26,8 +26,9 @@ import type { PlaywrightInitializeResult } from './playwrightConnection'; export class PlaywrightWebSocketServer { private _wsServer: WSServer; private _browser: Browser; + private _path = '/'; - constructor(browser: Browser, path: string) { + constructor(browser: Browser) { this._browser = browser; browser.on(Browser.Events.Disconnected, () => this.close()); @@ -38,6 +39,7 @@ export class PlaywrightWebSocketServer { }, onUpgrade: () => undefined, onHeaders: () => {}, + isAllowedPathname: pathname => pathname === this._path, onConnection: (request, url, ws, id) => { debugLogger.log('server', `[${id}] ws client connected`); return new PlaywrightConnection( @@ -62,7 +64,8 @@ export class PlaywrightWebSocketServer { } async listen(port: number = 0, hostname?: string, path?: string): Promise { - return await this._wsServer.listen(port, hostname, path || '/'); + this._path = path || '/'; + return await this._wsServer.listen(port, hostname, this._path); } async close() { diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 56fff6415a054..9a58e9b2b9295 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -222,7 +222,7 @@ export class BrowserServer { let endpoint: string; if (options.host !== undefined || options.port !== undefined) { - this._wsServer = new PlaywrightWebSocketServer(this._browser, '/'); + this._wsServer = new PlaywrightWebSocketServer(this._browser); endpoint = await this._wsServer.listen(options.port ?? 0, options.host, '/' + createGuid()); } else { this._pipeServer = new PlaywrightPipeServer(this._browser); diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 6be57ad5555d9..d0c7e50bcc2ac 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -95,7 +95,7 @@ export class CDPRelayServer { }, onHeaders: () => {}, onUpgrade: () => undefined, - isValidPathname: pathname => pathname === this._cdpPath || pathname === this._extensionPath, + isAllowedPathname: pathname => pathname === this._cdpPath || pathname === this._extensionPath, onConnection: (request, url, ws) => { debugLogger(`New connection to ${url.pathname}`); if (url.pathname === this._cdpPath) diff --git a/packages/utils/wsServer.ts b/packages/utils/wsServer.ts index 84880ac89aa05..e1d1232641e1d 100644 --- a/packages/utils/wsServer.ts +++ b/packages/utils/wsServer.ts @@ -47,8 +47,7 @@ export type WSServerDelegate = { onHeaders: (headers: string[]) => void; onUpgrade: (request: http.IncomingMessage, socket: stream.Duplex) => { error: string } | undefined; onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection; - // Overrides the default `pathname === path` check on upgrade requests. - isValidPathname?: (pathname: string) => boolean; + isAllowedPathname: (pathname: string) => boolean; }; export class WSServer { @@ -103,8 +102,7 @@ export class WSServer { server.on('upgrade', (request, socket, head) => { const pathname = new URL('http://localhost' + request.url!).pathname; - const isValidPathname = this._delegate.isValidPathname ?? (pathname => pathname === path); - if (!isValidPathname(pathname)) { + if (!this._delegate.isAllowedPathname(pathname)) { socket.write(`HTTP/${request.httpVersion} 400 Bad Request\r\n\r\n`); socket.destroy(); return; From f0aaa97006da21cd9a43d4d348f11f6941c75961 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 3 Aug 2026 16:42:40 -0700 Subject: [PATCH 6/7] chore: reuse isAllowedHost from httpServer in WSServer Also pass the ws path to the PlaywrightWebSocketServer constructor instead of listen(). --- .../src/remote/playwrightWebSocketServer.ts | 9 +++++---- .../playwright-core/src/server/browser.ts | 4 ++-- packages/utils/httpServer.ts | 4 ++-- packages/utils/wsServer.ts | 20 ++++++------------- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/playwright-core/src/remote/playwrightWebSocketServer.ts b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts index 44abafd007323..9465f8b442633 100644 --- a/packages/playwright-core/src/remote/playwrightWebSocketServer.ts +++ b/packages/playwright-core/src/remote/playwrightWebSocketServer.ts @@ -26,10 +26,12 @@ import type { PlaywrightInitializeResult } from './playwrightConnection'; export class PlaywrightWebSocketServer { private _wsServer: WSServer; private _browser: Browser; - private _path = '/'; + private _path: string; - constructor(browser: Browser) { + constructor(browser: Browser, path: string) { this._browser = browser; + this._path = path; + browser.on(Browser.Events.Disconnected, () => this.close()); const semaphore = new Semaphore(Infinity); @@ -63,8 +65,7 @@ export class PlaywrightWebSocketServer { }; } - async listen(port: number = 0, hostname?: string, path?: string): Promise { - this._path = path || '/'; + async listen(port: number = 0, hostname?: string): Promise { return await this._wsServer.listen(port, hostname, this._path); } diff --git a/packages/playwright-core/src/server/browser.ts b/packages/playwright-core/src/server/browser.ts index 9a58e9b2b9295..608fdab72caf2 100644 --- a/packages/playwright-core/src/server/browser.ts +++ b/packages/playwright-core/src/server/browser.ts @@ -222,8 +222,8 @@ export class BrowserServer { let endpoint: string; if (options.host !== undefined || options.port !== undefined) { - this._wsServer = new PlaywrightWebSocketServer(this._browser); - endpoint = await this._wsServer.listen(options.port ?? 0, options.host, '/' + createGuid()); + this._wsServer = new PlaywrightWebSocketServer(this._browser, '/' + createGuid()); + endpoint = await this._wsServer.listen(options.port ?? 0, options.host); } else { this._pipeServer = new PlaywrightPipeServer(this._browser); this._pipeSocketPath = await this._socketPath(); diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index b1bf75c885858..5baccda98fa61 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -311,7 +311,7 @@ export function computeAllowedHosts(requested: string | undefined, bound: string } // A null allowlist disables the check (server deliberately bound to a public address). -function isAllowedHost(request: http.IncomingMessage, allowedHosts: Set | null): boolean { +export function isAllowedHost(request: http.IncomingMessage, allowedHosts: Set | null): boolean { if (!allowedHosts) return true; const host = request.headers.host?.toLowerCase(); @@ -324,7 +324,7 @@ export function urlHostFromAddress(address: { address: string, family: string }) return address.family === 'IPv6' ? `[${address.address}]` : address.address; } -export function hostnameFromHostHeader(host: string): string { +function hostnameFromHostHeader(host: string): string { if (host.startsWith('[')) { const end = host.indexOf(']'); return end < 0 ? host : host.substring(0, end + 1); diff --git a/packages/utils/wsServer.ts b/packages/utils/wsServer.ts index e1d1232641e1d..623bafbf45db1 100644 --- a/packages/utils/wsServer.ts +++ b/packages/utils/wsServer.ts @@ -15,7 +15,7 @@ */ import { WebSocketServer as wsServer } from 'ws'; -import { computeAllowedHosts, hostnameFromHostHeader, urlHostFromAddress } from './httpServer'; +import { computeAllowedHosts, isAllowedHost, urlHostFromAddress } from './httpServer'; import { createHttpServer } from './network'; import { debugLogger } from './debugLogger'; @@ -61,7 +61,7 @@ export class WSServer { this._delegate = delegate; } - async listen(port: number = 0, hostname: string | undefined, path: string): Promise { + async listen(port: number = 0, hostname: string | undefined, defaultPath: string): Promise { debugLogger.log('server', `Server started at ${new Date()}`); // Default to loopback so the WebSocket RPC is not exposed to the network unless @@ -80,14 +80,14 @@ export class WSServer { return; } if (typeof address === 'string') { - resolve(`${address}${path}`); + resolve(`${address}${defaultPath}`); return; } // Advertise the bound IP literal in the wsEndpoint so the client connects to // the same address family the server bound to. Otherwise the client and // server resolvers can disagree on what 'localhost' means (see #40605). this._allowedHosts = computeAllowedHosts(hostname, address.address); - resolve(`ws://${urlHostFromAddress(address)}:${address.port}${path}`); + resolve(`ws://${urlHostFromAddress(address)}:${address.port}${defaultPath}`); }).on('error', reject); }); @@ -107,7 +107,7 @@ export class WSServer { socket.destroy(); return; } - if (!this._isAllowedHost(request) || !this._isAllowedOrigin(request.headers.origin)) { + if (!isAllowedHost(request, this._allowedHosts) || !this._isAllowedOrigin(request.headers.origin)) { socket.write(`HTTP/${request.httpVersion} 403 Forbidden\r\n\r\n`); socket.destroy(); return; @@ -134,7 +134,7 @@ export class WSServer { } private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) { - if (!this._isAllowedHost(request)) { + if (!isAllowedHost(request, this._allowedHosts)) { response.statusCode = 403; response.end(); return; @@ -142,14 +142,6 @@ export class WSServer { this._delegate.onRequest(request, response); } - private _isAllowedHost(request: http.IncomingMessage): boolean { - if (!this._allowedHosts) - return true; - const host = request.headers.host?.toLowerCase(); - const hostname = host ? hostnameFromHostHeader(host) : undefined; - return !!hostname && this._allowedHosts.has(hostname); - } - private _isAllowedOrigin(origin: string | undefined): boolean { if (!this._allowedHosts || !origin) return true; From 368e0589583b011ba9a398ead0fbf330d3ee82dc Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 3 Aug 2026 17:07:45 -0700 Subject: [PATCH 7/7] chore: cleanups from code review Allow WSServerDelegate.onConnection to return undefined instead of a no-op connection, align the upgrade 403 status line in HttpServer with WSServer. --- packages/playwright-core/src/tools/mcp/cdpRelay.ts | 2 +- packages/utils/httpServer.ts | 2 +- packages/utils/wsServer.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index d0c7e50bcc2ac..e24ae1a6c42d8 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -102,7 +102,7 @@ export class CDPRelayServer { this._handlePlaywrightConnection(ws); else this._handleExtensionConnection(ws); - return { close: async () => {} }; + return undefined; }, }); } diff --git a/packages/utils/httpServer.ts b/packages/utils/httpServer.ts index 5baccda98fa61..e85b30d4af686 100644 --- a/packages/utils/httpServer.ts +++ b/packages/utils/httpServer.ts @@ -90,7 +90,7 @@ export class HttpServer { if (pathname !== wsPath) return; if (!isAllowedHost(request, this._allowedHosts)) { - socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); + socket.write(`HTTP/${request.httpVersion} 403 Forbidden\r\n\r\n`); socket.destroy(); return; } diff --git a/packages/utils/wsServer.ts b/packages/utils/wsServer.ts index 623bafbf45db1..1715dea87c4f8 100644 --- a/packages/utils/wsServer.ts +++ b/packages/utils/wsServer.ts @@ -46,7 +46,7 @@ export type WSServerDelegate = { onRequest: (request: http.IncomingMessage, response: http.ServerResponse) => void; onHeaders: (headers: string[]) => void; onUpgrade: (request: http.IncomingMessage, socket: stream.Duplex) => { error: string } | undefined; - onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection; + onConnection: (request: http.IncomingMessage, url: URL, ws: WebSocket, id: string) => WSConnection | undefined; isAllowedPathname: (pathname: string) => boolean; };