Persist one browser user agent for Axios and WebSockets

This commit is contained in:
Brandon Li
2026-10-19 06:08:23 -05:00
parent 13e03c3d0f
commit ed56e306d1
21 changed files with 380 additions and 64 deletions
+201
View File
@@ -0,0 +1,201 @@
// Real local HTTP requests and WebSocket handshakes. Never use the live DB
// or contact a broker. The existing check suite uses Node's type stripping.
import { after, test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerHooks } from 'node:module';
import { execFileSync } from 'node:child_process';
import { createServer } from 'node:http';
import { connect } from 'node:net';
import { once } from 'node:events';
import { WebSocketServer } from 'ws';
import { HubConnectionBuilder, HttpTransportType, LogLevel } from '@microsoft/signalr';
// Resolve the extensionless local imports used by Next, without a build.
function resolveTypes(specifier, context, nextResolve) {
try {
return nextResolve(specifier, context);
} catch (err) {
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
return nextResolve(`${specifier}.ts`, context);
}
}
registerHooks({ resolve: resolveTypes });
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-user-agent-'));
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
delete process.env.NEXT_PHASE;
after(() => rmSync(directory, { recursive: true, force: true }));
const { getSetting, setSetting } = await import('../lib/db.ts');
// Do not let inherited environment proxy settings route local test traffic.
process.env.NO_PROXY = '*';
setSetting('proxy_url', '');
const { getRequestUserAgent } = await import('../lib/request-user-agent.ts');
const { default: axios } = await import('../lib/http-client.ts');
const { brokerWebSocket, directWebSocket, signalrWebSocket } = await import('../lib/outbound-proxy.ts');
const userAgent = getRequestUserAgent();
async function listen(server) {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return `http://127.0.0.1:${server.address().port}`;
}
async function socketHeaders(makeSocket, server, url, path = '/') {
const accepted = once(server, 'connection');
const client = makeSocket(url.replace('http:', 'ws:') + path);
const opened = new Promise((resolve, reject) => {
client.addEventListener('open', resolve, { once: true });
client.addEventListener('error', reject, { once: true });
});
const [peer, request] = await accepted;
try {
await opened;
return request.headers;
} finally {
client.close();
peer.terminate();
}
}
test('first load saves a browser identity and another process reuses it', () => {
assert.match(userAgent, /^Mozilla\/5\.0 .*(Chrome\/|Firefox\/)/);
assert.equal(getSetting('user_agent'), userAgent);
const value = execFileSync(process.execPath, [
'--experimental-strip-types', '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--input-type=module', '-e',
`import { registerHooks } from 'node:module';
registerHooks({ resolve: ${resolveTypes.toString()} });
const { getRequestUserAgent } = await import(${JSON.stringify(new URL('../lib/request-user-agent.ts', import.meta.url).href)});
process.stdout.write(getRequestUserAgent());`,
], { env: process.env, encoding: 'utf8' });
assert.equal(value, userAgent);
});
test('Axios calls, custom headers, and nested create clients use the saved identity', async () => {
const server = createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(req.headers));
});
const url = await listen(server);
try {
const child = axios.create({ headers: { 'user-agent': 'old child identity' } });
const grandchild = child.create({ headers: { 'User-Agent': false } });
for (const client of [axios, child, grandchild]) {
const result = await client.post(url, { hello: 'world' }, {
headers: { 'uSeR-aGeNt': false, Authorization: 'Bearer local-test', 'X-Test': 'kept' },
});
assert.equal(result.data['user-agent'], userAgent);
assert.equal(result.data.authorization, 'Bearer local-test');
assert.equal(result.data['x-test'], 'kept');
assert.equal(client.defaults.timeout, 60_000);
}
assert.equal((await axios(url)).data['user-agent'], userAgent);
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
test('direct, broker and SignalR handshakes preserve the saved identity and auth', async () => {
const server = createServer();
const wss = new WebSocketServer({ server });
const url = await listen(server);
try {
for (const makeSocket of [directWebSocket, brokerWebSocket]) {
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
}
const WS = signalrWebSocket();
for (const overload of [false, true]) {
const headers = await socketHeaders((address) => {
const options = { headers: { 'user-agent': 'SignalR default', Cookie: 'session=local', Authorization: 'Bearer local-test' } };
return overload ? new WS(address, options) : new WS(address, undefined, options);
}, wss, url);
assert.equal(headers['user-agent'], userAgent);
assert.equal(headers.cookie, 'session=local');
assert.equal(headers.authorization, 'Bearer local-test');
}
// Exercise SignalR itself, including its generated UA/auth headers.
wss.on('connection', (socket) => {
socket.on('message', () => socket.send('{}\u001e'));
});
const hub = new HubConnectionBuilder().withUrl(url, {
skipNegotiation: true,
transport: HttpTransportType.WebSockets,
WebSocket: WS,
accessTokenFactory: () => 'signalr-test',
}).configureLogging(LogLevel.None).build();
const accepted = once(wss, 'connection');
await hub.start();
try {
const [, request] = await accepted;
assert.equal(request.headers['user-agent'], userAgent);
assert.equal(request.headers.authorization, 'Bearer signalr-test');
} finally {
await hub.stop();
}
} finally {
for (const socket of wss.clients) socket.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => server.close(resolve));
}
});
test('HTTP proxy keeps the identity on broker sockets while control sockets stay direct', async () => {
const server = createServer();
const wss = new WebSocketServer({ server });
const url = await listen(server);
let tunnels = 0;
const connections = new Set();
const proxy = createServer((req, res) => {
// ws with HttpProxyAgent uses an absolute-form upgrade; handled below.
res.writeHead(500).end();
});
function tunnel(req, socket, head, isConnect) {
tunnels++;
const target = new URL(isConnect ? `http://${req.url}` : req.url);
const upstream = connect(Number(target.port), target.hostname, () => {
if (isConnect) socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
else {
upstream.write(`${req.method} ${target.pathname} HTTP/1.1\r\n`);
for (let i = 0; i < req.rawHeaders.length; i += 2) upstream.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
upstream.write('\r\n');
}
if (head.length) upstream.write(head);
socket.pipe(upstream).pipe(socket);
});
connections.add(socket);
connections.add(upstream);
socket.on('error', () => upstream.destroy());
upstream.on('error', () => socket.destroy());
}
proxy.on('connect', (req, socket, head) => tunnel(req, socket, head, true));
proxy.on('upgrade', (req, socket, head) => tunnel(req, socket, head, false));
const proxyUrl = await listen(proxy);
setSetting('proxy_url', proxyUrl);
delete globalThis.__outboundProxy;
try {
for (const makeSocket of [brokerWebSocket, (address) => new (signalrWebSocket())(address)]) {
const before = tunnels;
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
assert.equal(tunnels, before + 1);
}
const before = tunnels;
assert.equal((await socketHeaders(directWebSocket, wss, url))['user-agent'], userAgent);
assert.equal(tunnels, before);
} finally {
await globalThis.__outboundProxy.dispatcher.destroy();
globalThis.__outboundProxy.httpAgent.destroy();
globalThis.__outboundProxy.httpsAgent.destroy();
delete globalThis.__outboundProxy;
setSetting('proxy_url', '');
for (const socket of connections) socket.destroy();
for (const socket of wss.clients) socket.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => server.close(resolve));
await new Promise((resolve) => proxy.close(resolve));
}
});