Persist one browser user agent for Axios and WebSockets
This commit is contained in:
@@ -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));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user