From be95268caca31bfc84fa165d327417b846db0dbc Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 11 May 2026 15:28:35 -0700 Subject: [PATCH 1/3] test(proxy): cover CONNECT 407 with socket-close reconnection Regression test for https://github.com/microsoft/playwright/issues/40768: some HTTP proxies respond to CONNECT with 407 and immediately destroy the socket, expecting the client to reopen a TCP connection and resend CONNECT with Proxy-Authorization. WebKit on Windows (libcurl) was hanging in this scenario. --- tests/library/proxy.spec.ts | 59 +++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 031cc71c7defb..802fb776aba4d 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -16,6 +16,7 @@ import { setupSocksForwardingServer } from '../config/proxy'; import { playwrightTest as it, expect } from '../config/browserTest'; +import http from 'http'; import net from 'net'; it.skip(({ mode }) => mode.startsWith('service')); @@ -164,6 +165,64 @@ it('should authenticate', async ({ browserType, server }) => { await browser.close(); }); +it('should reconnect with credentials after CONNECT 407 closes the socket', async ({ browserType, httpsServer }) => { + // Reproduces https://github.com/microsoft/playwright/issues/40768: some HTTP + // proxies send 407 and destroy the socket on the first CONNECT, expecting + // the client to reconnect on a new TCP connection with Proxy-Authorization. + httpsServer.setRoute('/target.html', async (req, res) => { + res.end('Served by https server via proxy'); + }); + + const connectAttempts: { hadAuth: boolean }[] = []; + const proxySockets = new Set(); + const proxy = http.createServer(); + proxy.on('connection', socket => { + proxySockets.add(socket); + socket.on('close', () => proxySockets.delete(socket)); + }); + proxy.on('connect', (req, clientSocket, head) => { + const auth = req.headers['proxy-authorization']; + connectAttempts.push({ hadAuth: !!auth }); + if (!auth) { + clientSocket.on('error', () => {}); + clientSocket.end( + 'HTTP/1.1 407 Proxy Authentication Required\r\n' + + 'Proxy-Authenticate: Basic realm="proxy"\r\n' + + 'Content-Length: 0\r\n' + + 'Connection: close\r\n' + + '\r\n', () => clientSocket.destroy()); + return; + } + const target = net.connect(httpsServer.PORT, '127.0.0.1', () => { + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + if (head && head.length) + target.write(head); + clientSocket.pipe(target).pipe(clientSocket); + }); + target.on('error', () => clientSocket.destroy()); + clientSocket.on('error', () => target.destroy()); + }); + await new Promise(f => proxy.listen(0, '127.0.0.1', f)); + const proxyPort = (proxy.address() as net.AddressInfo).port; + + const browser = await browserType.launch({ + proxy: { server: `http://127.0.0.1:${proxyPort}`, username: 'user', password: 'secret' }, + }); + try { + const page = await browser.newPage({ ignoreHTTPSErrors: true }); + await page.goto('https://non-existent.com/target.html'); + expect(await page.title()).toBe('Served by https server via proxy'); + expect(connectAttempts.length).toBeGreaterThanOrEqual(2); + expect(connectAttempts[0].hadAuth).toBe(false); + expect(connectAttempts.some(a => a.hadAuth)).toBe(true); + } finally { + await browser.close(); + for (const socket of proxySockets) + socket.destroy(); + await new Promise(f => proxy.close(() => f())); + } +}); + it('should work with authenticate followed by redirect', async ({ browserName, browserType, server }) => { function hasAuth(req, res) { const auth = req.headers['proxy-authorization']; From 8b9a8745086400f195a17d056c340c72bc041c10 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Fri, 15 May 2026 15:11:36 -0700 Subject: [PATCH 2/3] test(proxy): use proxyServer fixture in CONNECT 407 reconnect test --- tests/library/proxy.spec.ts | 66 ++++++++++--------------------------- 1 file changed, 18 insertions(+), 48 deletions(-) diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 802fb776aba4d..7cebe460f1f01 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -16,7 +16,6 @@ import { setupSocksForwardingServer } from '../config/proxy'; import { playwrightTest as it, expect } from '../config/browserTest'; -import http from 'http'; import net from 'net'; it.skip(({ mode }) => mode.startsWith('service')); @@ -165,62 +164,33 @@ it('should authenticate', async ({ browserType, server }) => { await browser.close(); }); -it('should reconnect with credentials after CONNECT 407 closes the socket', async ({ browserType, httpsServer }) => { - // Reproduces https://github.com/microsoft/playwright/issues/40768: some HTTP - // proxies send 407 and destroy the socket on the first CONNECT, expecting +it('should reconnect with credentials after CONNECT 407 closes the socket', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40768' } +}, async ({ browserType, httpsServer, proxyServer }) => { + // Some HTTP proxies send 407 and close the socket on the first CONNECT, expecting // the client to reconnect on a new TCP connection with Proxy-Authorization. httpsServer.setRoute('/target.html', async (req, res) => { res.end('Served by https server via proxy'); }); + proxyServer.forwardTo(httpsServer.PORT, { allowConnectRequests: true }); const connectAttempts: { hadAuth: boolean }[] = []; - const proxySockets = new Set(); - const proxy = http.createServer(); - proxy.on('connection', socket => { - proxySockets.add(socket); - socket.on('close', () => proxySockets.delete(socket)); + proxyServer.setAuthHandler(req => { + if (req.method === 'CONNECT') + connectAttempts.push({ hadAuth: !!req.headers['proxy-authorization'] }); + return !!req.headers['proxy-authorization']; }); - proxy.on('connect', (req, clientSocket, head) => { - const auth = req.headers['proxy-authorization']; - connectAttempts.push({ hadAuth: !!auth }); - if (!auth) { - clientSocket.on('error', () => {}); - clientSocket.end( - 'HTTP/1.1 407 Proxy Authentication Required\r\n' + - 'Proxy-Authenticate: Basic realm="proxy"\r\n' + - 'Content-Length: 0\r\n' + - 'Connection: close\r\n' + - '\r\n', () => clientSocket.destroy()); - return; - } - const target = net.connect(httpsServer.PORT, '127.0.0.1', () => { - clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); - if (head && head.length) - target.write(head); - clientSocket.pipe(target).pipe(clientSocket); - }); - target.on('error', () => clientSocket.destroy()); - clientSocket.on('error', () => target.destroy()); - }); - await new Promise(f => proxy.listen(0, '127.0.0.1', f)); - const proxyPort = (proxy.address() as net.AddressInfo).port; const browser = await browserType.launch({ - proxy: { server: `http://127.0.0.1:${proxyPort}`, username: 'user', password: 'secret' }, - }); - try { - const page = await browser.newPage({ ignoreHTTPSErrors: true }); - await page.goto('https://non-existent.com/target.html'); - expect(await page.title()).toBe('Served by https server via proxy'); - expect(connectAttempts.length).toBeGreaterThanOrEqual(2); - expect(connectAttempts[0].hadAuth).toBe(false); - expect(connectAttempts.some(a => a.hadAuth)).toBe(true); - } finally { - await browser.close(); - for (const socket of proxySockets) - socket.destroy(); - await new Promise(f => proxy.close(() => f())); - } + proxy: { server: proxyServer.HOST, username: 'user', password: 'secret' }, + }); + const page = await browser.newPage({ ignoreHTTPSErrors: true }); + await page.goto('https://non-existent.com/target.html'); + expect(await page.title()).toBe('Served by https server via proxy'); + expect(connectAttempts.length).toBeGreaterThanOrEqual(2); + expect(connectAttempts[0].hadAuth).toBe(false); + expect(connectAttempts.some(a => a.hadAuth)).toBe(true); + await browser.close(); }); it('should work with authenticate followed by redirect', async ({ browserName, browserType, server }) => { From 774972fbe61658cb5bfba906a965bed3e1c1ab53 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Mon, 18 May 2026 11:38:08 -0700 Subject: [PATCH 3/3] test(proxy): force first CONNECT to fail to exercise reconnect on all platforms WebKit on Windows uses libcurl and sends Proxy-Authorization preemptively on every CONNECT via CURLOPT_PROXYHEADER, while libsoup on Linux/macOS sends it only after a 407 challenge. The previous test assumed the Linux/macOS lazy-auth flow. --- tests/library/proxy.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/library/proxy.spec.ts b/tests/library/proxy.spec.ts index 7cebe460f1f01..3a01d101de750 100644 --- a/tests/library/proxy.spec.ts +++ b/tests/library/proxy.spec.ts @@ -175,9 +175,19 @@ it('should reconnect with credentials after CONNECT 407 closes the socket', { proxyServer.forwardTo(httpsServer.PORT, { allowConnectRequests: true }); const connectAttempts: { hadAuth: boolean }[] = []; + let closedFirstConnect = false; proxyServer.setAuthHandler(req => { - if (req.method === 'CONNECT') - connectAttempts.push({ hadAuth: !!req.headers['proxy-authorization'] }); + // WebKit on Windows uses libcurl and sends Proxy-Authorization preemptively + // on every CONNECT, while libsoup on Linux/macOS sends it only after a 407. + // Force the first CONNECT to fail regardless to deterministically exercise + // the reconnect path on all platforms. + if (req.method !== 'CONNECT' || !req.headers.host?.startsWith('non-existent.com')) + return true; + connectAttempts.push({ hadAuth: !!req.headers['proxy-authorization'] }); + if (!closedFirstConnect) { + closedFirstConnect = true; + return false; + } return !!req.headers['proxy-authorization']; }); @@ -188,7 +198,6 @@ it('should reconnect with credentials after CONNECT 407 closes the socket', { await page.goto('https://non-existent.com/target.html'); expect(await page.title()).toBe('Served by https server via proxy'); expect(connectAttempts.length).toBeGreaterThanOrEqual(2); - expect(connectAttempts[0].hadAuth).toBe(false); expect(connectAttempts.some(a => a.hadAuth)).toBe(true); await browser.close(); });