Skip to content

Commit 71537d2

Browse files
committed
feat(test): enhance SOCKS5 server with cookie handling and WebSocket support
1 parent a599d61 commit 71537d2

1 file changed

Lines changed: 185 additions & 5 deletions

File tree

test/e2e/proxy.test.js

Lines changed: 185 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const assert = require('node:assert/strict');
33
const childProcess = require('node:child_process');
44
const fs = require('node:fs');
55
const http = require('node:http');
6+
const crypto = require('node:crypto');
67
const net = require('node:net');
78
const os = require('node:os');
89
const path = require('node:path');
@@ -94,27 +95,129 @@ class SocketReader {
9495
}
9596

9697
function 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

120223
function 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, /target_server=from-target/);
503+
assert.match(runtimeIntegration.visibleCookie, /client_runtime=from-runtime/);
504+
assert.match(runtimeIntegration.clientCookie, /target_server=from-target/);
505+
assert.match(runtimeIntegration.clientCookie, /client_runtime=from-runtime/);
506+
assert.equal(runtimeIntegration.stream.status, 200);
507+
assert.match(runtimeIntegration.stream.contentType, /^text\/plain/);
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

Comments
 (0)