@@ -3,6 +3,7 @@ const assert = require('node:assert/strict');
33const childProcess = require ( 'node:child_process' ) ;
44const fs = require ( 'node:fs' ) ;
55const http = require ( 'node:http' ) ;
6+ const crypto = require ( 'node:crypto' ) ;
67const net = require ( 'node:net' ) ;
78const os = require ( 'node:os' ) ;
89const path = require ( 'node:path' ) ;
@@ -94,27 +95,129 @@ class SocketReader {
9495}
9596
9697function createTargetServer ( requests ) {
97- return http . createServer ( ( req , res ) => {
98- requests . push ( { url : req . url , userAgent : req . headers [ 'user-agent' ] || '' } ) ;
99- if ( req . url === '/' ) {
98+ const server = http . createServer ( ( req , res ) => {
99+ requests . push ( { url : req . url , method : req . method , userAgent : req . headers [ 'user-agent' ] || '' , cookie : req . headers . cookie || '' } ) ;
100+ const url = new URL ( req . url , 'http://target.local' ) ;
101+ if ( url . pathname === '/' ) {
100102 res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
101103 res . end ( `<!doctype html><html><head><title>E2E Home</title></head><body>
102104 <main><h1>E2E Home</h1><a id="next" href="/next">Next page</a></main>
103105 <script>window.__ua = navigator.userAgent; window.__platform = navigator.platform;</script>
104106 </body></html>` ) ;
105107 return ;
106108 }
107- if ( req . url === '/next' ) {
109+ if ( url . pathname === '/next' ) {
108110 res . writeHead ( 200 , { 'Content-Type' : 'text/html; charset=utf-8' } ) ;
109111 res . end ( `<!doctype html><html><head><title>E2E Next</title></head><body>
110112 <main><h1>E2E Next</h1><p id="ua"></p></main>
111113 <script>document.getElementById('ua').textContent = navigator.userAgent;</script>
112114 </body></html>` ) ;
113115 return ;
114116 }
117+ if ( url . pathname === '/set-cookie' ) {
118+ res . writeHead ( 200 , {
119+ 'Content-Type' : 'text/plain; charset=utf-8' ,
120+ 'Cache-Control' : 'no-store' ,
121+ 'Set-Cookie' : 'target_server=from-target; Path=/; SameSite=Lax' ,
122+ } ) ;
123+ res . end ( 'set-cookie-ok' ) ;
124+ return ;
125+ }
126+ if ( url . pathname === '/cookie-echo' ) {
127+ res . writeHead ( 200 , { 'Content-Type' : 'text/plain; charset=utf-8' , 'Cache-Control' : 'no-store' } ) ;
128+ res . end ( req . headers . cookie || '' ) ;
129+ return ;
130+ }
131+ if ( url . pathname === '/stream' ) {
132+ res . writeHead ( 200 , { 'Content-Type' : 'text/plain; charset=utf-8' , 'Cache-Control' : 'no-store' } ) ;
133+ res . write ( 'chunk-one\n' ) ;
134+ setTimeout ( ( ) => res . end ( 'chunk-two\n' ) , 600 ) ;
135+ return ;
136+ }
115137 res . writeHead ( 404 , { 'Content-Type' : 'text/plain; charset=utf-8' } ) ;
116138 res . end ( 'not found' ) ;
117139 } ) ;
140+ server . on ( 'upgrade' , ( req , socket ) => handleWebSocketUpgrade ( req , socket , requests ) ) ;
141+ return server ;
142+ }
143+
144+ function handleWebSocketUpgrade ( req , socket , requests ) {
145+ requests . push ( { url : req . url , method : req . method , userAgent : req . headers [ 'user-agent' ] || '' , cookie : req . headers . cookie || '' , upgrade : true } ) ;
146+ if ( new URL ( req . url , 'http://target.local' ) . pathname !== '/ws' ) {
147+ socket . end ( 'HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n' ) ;
148+ return ;
149+ }
150+ const key = req . headers [ 'sec-websocket-key' ] ;
151+ if ( ! key ) {
152+ socket . end ( 'HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n' ) ;
153+ return ;
154+ }
155+ const accept = crypto . createHash ( 'sha1' ) . update ( key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' ) . digest ( 'base64' ) ;
156+ socket . write ( 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n' ) ;
157+ let buffered = Buffer . alloc ( 0 ) ;
158+ socket . on ( 'data' , chunk => {
159+ buffered = Buffer . concat ( [ buffered , chunk ] ) ;
160+ while ( buffered . length >= 2 ) {
161+ const frame = readWebSocketFrame ( buffered ) ;
162+ if ( ! frame ) break ;
163+ buffered = buffered . subarray ( frame . consumed ) ;
164+ if ( frame . opcode === 0x8 ) {
165+ writeWebSocketFrame ( socket , 0x8 ) ;
166+ socket . end ( ) ;
167+ return ;
168+ }
169+ if ( frame . opcode === 0x9 ) {
170+ writeWebSocketFrame ( socket , 0xA , frame . payload ) ;
171+ continue ;
172+ }
173+ if ( frame . opcode === 0x1 ) writeWebSocketFrame ( socket , 0x1 , Buffer . from ( 'echo:' + frame . payload . toString ( 'utf8' ) ) ) ;
174+ }
175+ } ) ;
176+ }
177+
178+ function readWebSocketFrame ( buffer ) {
179+ const b0 = buffer [ 0 ] ;
180+ const b1 = buffer [ 1 ] ;
181+ const masked = ( b1 & 0x80 ) !== 0 ;
182+ let length = b1 & 0x7f ;
183+ let offset = 2 ;
184+ if ( length === 126 ) {
185+ if ( buffer . length < offset + 2 ) return null ;
186+ length = buffer . readUInt16BE ( offset ) ;
187+ offset += 2 ;
188+ } else if ( length === 127 ) {
189+ if ( buffer . length < offset + 8 ) return null ;
190+ length = Number ( buffer . readBigUInt64BE ( offset ) ) ;
191+ offset += 8 ;
192+ }
193+ const maskOffset = offset ;
194+ if ( masked ) offset += 4 ;
195+ if ( buffer . length < offset + length ) return null ;
196+ const payload = Buffer . from ( buffer . subarray ( offset , offset + length ) ) ;
197+ if ( masked ) {
198+ const mask = buffer . subarray ( maskOffset , maskOffset + 4 ) ;
199+ for ( let i = 0 ; i < payload . length ; i ++ ) payload [ i ] ^= mask [ i % 4 ] ;
200+ }
201+ return { opcode : b0 & 0x0f , payload, consumed : offset + length } ;
202+ }
203+
204+ function writeWebSocketFrame ( socket , opcode , data = Buffer . alloc ( 0 ) ) {
205+ const payload = Buffer . isBuffer ( data ) ? data : Buffer . from ( data ) ;
206+ let header ;
207+ if ( payload . length < 126 ) {
208+ header = Buffer . from ( [ 0x80 | opcode , payload . length ] ) ;
209+ } else if ( payload . length <= 0xffff ) {
210+ header = Buffer . alloc ( 4 ) ;
211+ header [ 0 ] = 0x80 | opcode ;
212+ header [ 1 ] = 126 ;
213+ header . writeUInt16BE ( payload . length , 2 ) ;
214+ } else {
215+ header = Buffer . alloc ( 10 ) ;
216+ header [ 0 ] = 0x80 | opcode ;
217+ header [ 1 ] = 127 ;
218+ header . writeBigUInt64BE ( BigInt ( payload . length ) , 2 ) ;
219+ }
220+ socket . write ( Buffer . concat ( [ header , payload ] ) ) ;
118221}
119222
120223function createSocks5Server ( resolveHost ) {
@@ -161,7 +264,7 @@ async function handleSocks(socket, resolveHost) {
161264 upstream . pipe ( socket ) ;
162265}
163266
164- test ( 'browser traffic uses test SOCKS5, proxied /p navigation, and Chrome UA ' , { timeout : 120000 } , async t => {
267+ test ( 'browser traffic uses test SOCKS5 and covers proxied runtime integrations ' , { timeout : 120000 } , async t => {
165268 const temp = fs . mkdtempSync ( path . join ( os . tmpdir ( ) , 'zeroproxy-e2e-' ) ) ;
166269 const kernelPath = path . join ( temp , 'kernel.wasm' ) ;
167270 const serverPath = path . join ( temp , process . platform === 'win32' ? 'zeroproxy-server.exe' : 'zeroproxy-server' ) ;
@@ -335,6 +438,83 @@ test('browser traffic uses test SOCKS5, proxied /p navigation, and Chrome UA', {
335438 assert . equal ( fingerprintMasking . voiceCount , 2 ) ;
336439 assert . deepEqual ( fingerprintMasking . voiceNames , [ 'Google US English' , 'Microsoft David - English (United States)' ] ) ;
337440
441+ const runtimeIntegration = await page . evaluate ( async targetPort => {
442+ async function readText ( path ) {
443+ const resp = await fetch ( path , { cache : 'no-store' } ) ;
444+ return resp . text ( ) ;
445+ }
446+ async function waitForCookieHeader ( needle ) {
447+ let last = '' ;
448+ for ( let i = 0 ; i < 30 ; i ++ ) {
449+ last = await readText ( '/cookie-echo?needle=' + encodeURIComponent ( needle ) + '&i=' + i ) ;
450+ if ( last . includes ( needle ) ) return last ;
451+ await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
452+ }
453+ throw new Error ( 'cookie header never contained ' + needle + ': ' + last ) ;
454+ }
455+ async function readStream ( ) {
456+ const started = performance . now ( ) ;
457+ const resp = await fetch ( '/stream?ts=' + Date . now ( ) , { cache : 'no-store' } ) ;
458+ const reader = resp . body . getReader ( ) ;
459+ const decoder = new TextDecoder ( ) ;
460+ const first = await reader . read ( ) ;
461+ const firstMs = performance . now ( ) - started ;
462+ let body = first . value ? decoder . decode ( first . value , { stream : true } ) : '' ;
463+ for ( ; ; ) {
464+ const next = await reader . read ( ) ;
465+ if ( next . done ) break ;
466+ body += decoder . decode ( next . value , { stream : true } ) ;
467+ }
468+ body += decoder . decode ( ) ;
469+ return { status : resp . status , contentType : resp . headers . get ( 'content-type' ) || '' , firstText : first . value ? decoder . decode ( first . value ) : '' , firstMs, body } ;
470+ }
471+ function websocketEcho ( ) {
472+ return new Promise ( ( resolve , reject ) => {
473+ const ws = new WebSocket ( 'ws://e2e.test:' + targetPort + '/ws' ) ;
474+ let settled = false ;
475+ const finish = fn => value => {
476+ if ( settled ) return ;
477+ settled = true ;
478+ clearTimeout ( timer ) ;
479+ fn ( value ) ;
480+ } ;
481+ const timer = setTimeout ( ( ) => finish ( reject ) ( new Error ( 'websocket echo timed out' ) ) , 10000 ) ;
482+ ws . onerror = ( ) => finish ( reject ) ( new Error ( 'websocket error' ) ) ;
483+ ws . onopen = ( ) => ws . send ( 'hello through proxy' ) ;
484+ ws . onmessage = ev => {
485+ const result = { url : ws . url , data : String ( ev . data ) , readyState : ws . readyState } ;
486+ try { ws . close ( 1000 , 'done' ) ; } catch { }
487+ finish ( resolve ) ( result ) ;
488+ } ;
489+ } ) ;
490+ }
491+
492+ const setCookieBody = await readText ( '/set-cookie?ts=' + Date . now ( ) ) ;
493+ const serverCookie = await waitForCookieHeader ( 'target_server=from-target' ) ;
494+ document . cookie = 'client_runtime=from-runtime; Path=/' ;
495+ const visibleCookie = document . cookie ;
496+ const clientCookie = await waitForCookieHeader ( 'client_runtime=from-runtime' ) ;
497+ const stream = await readStream ( ) ;
498+ const ws = await websocketEcho ( ) ;
499+ return { setCookieBody, serverCookie, visibleCookie, clientCookie, stream, ws } ;
500+ } , targetPort ) ;
501+ assert . equal ( runtimeIntegration . setCookieBody , 'set-cookie-ok' ) ;
502+ assert . match ( runtimeIntegration . serverCookie , / t a r g e t _ s e r v e r = f r o m - t a r g e t / ) ;
503+ assert . match ( runtimeIntegration . visibleCookie , / c l i e n t _ r u n t i m e = f r o m - r u n t i m e / ) ;
504+ assert . match ( runtimeIntegration . clientCookie , / t a r g e t _ s e r v e r = f r o m - t a r g e t / ) ;
505+ assert . match ( runtimeIntegration . clientCookie , / c l i e n t _ r u n t i m e = f r o m - r u n t i m e / ) ;
506+ assert . equal ( runtimeIntegration . stream . status , 200 ) ;
507+ assert . match ( runtimeIntegration . stream . contentType , / ^ t e x t \/ p l a i n / ) ;
508+ assert . equal ( runtimeIntegration . stream . firstText , 'chunk-one\n' ) ;
509+ assert . equal ( runtimeIntegration . stream . body , 'chunk-one\nchunk-two\n' ) ;
510+ assert . ok ( runtimeIntegration . stream . firstMs < 500 , `stream first chunk was buffered for ${ runtimeIntegration . stream . firstMs } ms` ) ;
511+ assert . equal ( runtimeIntegration . ws . url , `ws://e2e.test:${ targetPort } /ws` ) ;
512+ assert . equal ( runtimeIntegration . ws . data , 'echo:hello through proxy' ) ;
513+ assert . ok ( requests . some ( r => r . url . startsWith ( '/set-cookie' ) && r . userAgent === TARGET_UA ) , `target requests: ${ JSON . stringify ( requests ) } ` ) ;
514+ assert . ok ( requests . some ( r => r . url . startsWith ( '/stream' ) && r . userAgent === TARGET_UA ) , `target requests: ${ JSON . stringify ( requests ) } ` ) ;
515+ assert . ok ( requests . some ( r => r . upgrade && r . url === '/ws' && r . userAgent === TARGET_UA ) , `target requests: ${ JSON . stringify ( requests ) } ` ) ;
516+ assert . ok ( requests . some ( r => r . url . startsWith ( '/cookie-echo' ) && r . cookie . includes ( 'target_server=from-target' ) && r . cookie . includes ( 'client_runtime=from-runtime' ) ) , `target requests: ${ JSON . stringify ( requests ) } ` ) ;
517+
338518 await page . click ( '#next' ) ;
339519 await page . waitForFunction ( ( ) => document . title === 'E2E Next' , { timeout : 30000 } ) ;
340520 const next = await page . evaluate ( ( ) => ( {
0 commit comments