Files
Tim Perry 31946e9999 Set up a native testing harness for low-level network testing
Primarily relevant for SOCKS, but probably more generally in future.
2026-09-08 17:40:47 +02:00

238 lines
9.3 KiB
TypeScript

/**
* Tests for native-connect-hook.js, run against a SOCKS server we can make misbehave.
*
* These run here rather than on a device because none of this is reachable from an app: a
* real proxy either works or it doesn't, where the interesting cases are the ones where it
* half-works. The Android suite covers that the hook intercepts real traffic; this covers
* what happens when the thing on the other end stops cooperating.
*/
import { expect } from 'chai';
import { HookedProcess } from './hooked-process.ts';
import { SocksServer, type SocksMode } from './socks-server.ts';
const DESTINATION = '93.184.216.34';
const DESTINATION_PORT = 443;
describe('The native connect hook', function () {
this.timeout(30_000);
let proxy: SocksServer | undefined;
let app: HookedProcess | undefined;
afterEach(async () => {
await app?.stop();
await proxy?.stop();
app = proxy = undefined;
});
// Starts a SOCKS proxy in the given mode, with the hook pointed at it:
const givenSocksProxy = async (mode: SocksMode = 'normal') => {
proxy = await SocksServer.start(mode);
app = await HookedProcess.start({
PROXY_HOST: `'127.0.0.1'`,
PROXY_PORT: proxy.port.toString(),
PROXY_SUPPORTS_SOCKS5: 'true'
});
return { proxy, app };
};
describe('given a working SOCKS proxy', () => {
it('tunnels connections, and the tunnel carries data cleanly', async () => {
const { app, proxy } = await givenSocksProxy();
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(0);
expect(proxy.requests).to.deep.equal([
{ addressType: 1, address: DESTINATION, port: DESTINATION_PORT }
]);
// The proxy echoes what it receives, so anything the handshake left unread
// would turn up in front of this:
expect(await app.echo('HELLO')).to.equal('HELLO');
});
it('sends IPv4-mapped destinations as plain IPv4', async () => {
// Android apps open AF_INET6 sockets for IPv4 destinations almost universally,
// so this is the common case rather than an edge case. The 16-byte form is
// handled inconsistently by SOCKS servers - mockttp rejects it outright.
const { app, proxy } = await givenSocksProxy();
const result = await app.connect({ port: DESTINATION_PORT, ipv6: true });
expect(result.result).to.equal(0);
expect(proxy.requests).to.deep.equal([
{ addressType: 1, address: DESTINATION, port: DESTINATION_PORT }
]);
expect(await app.echo('HELLO')).to.equal('HELLO');
});
it('leaves a non-blocking socket non-blocking', async () => {
// We clear O_NONBLOCK to run the handshake. Not putting it back would leave the
// app's socket blocking for the rest of its life.
const { app } = await givenSocksProxy();
const result = await app.connect({
port: DESTINATION_PORT,
nonBlocking: true
});
expect(result.result).to.equal(0);
expect(result.nonBlocking).to.equal(true);
});
it('handles a proxy that replies one byte at a time', async () => {
// recv() returns as soon as any data is available, so a reply split across
// packets is misparsed by anything that assumes otherwise - and the leftovers
// are then delivered to the application as if the server had sent them.
const { app } = await givenSocksProxy('dribble');
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(0);
expect(await app.echo('HELLO')).to.equal('HELLO');
});
});
describe('given a proxy that stops cooperating', () => {
// The everyday version of this is the USB cable coming out. There's nothing to
// recover, so all we want is a prompt, clearly reported failure.
it('gives up on a proxy that never replies', async () => {
const { app } = await givenSocksProxy('silent');
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(-1);
expect(result.durationMs).to.be.lessThan(20_000);
});
it('gives up on a proxy that stalls mid-handshake', async () => {
const { app } = await givenSocksProxy('stalls');
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(-1);
expect(result.durationMs).to.be.lessThan(20_000);
});
it('reports a proxy refusing the connection as a failed connect', async () => {
const { app } = await givenSocksProxy('refuses');
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(-1);
expect(app.logs.join('\n')).to.include('SOCKS: Server returned error code 5');
});
it('fails cleanly when the proxy demands an auth method we do not offer', async () => {
const { app } = await givenSocksProxy('demands-auth');
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(-1);
expect(app.logs.join('\n')).to.include('SOCKS: Server rejected auth method');
});
it('refuses a hostname reply rather than misreading the stream', async () => {
// ATYP 3 is legal, but nothing we proxy through replies with one. Failing beats
// guessing at how many bytes of address to consume.
const { app } = await givenSocksProxy('names-itself');
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(-1);
expect(app.logs.join('\n')).to.include('unsupported address type 3');
});
it('reports a failure the application can interpret', async () => {
// -1 with errno left at 0 isn't a failure any application knows how to handle.
const { app } = await givenSocksProxy('silent');
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(-1);
expect(result.errno).to.equal(111); // ECONNREFUSED
});
it('leaves a non-blocking socket non-blocking even when the handshake fails', async () => {
const { app } = await givenSocksProxy('silent');
const result = await app.connect({
port: DESTINATION_PORT,
nonBlocking: true
});
expect(result.result).to.equal(-1);
expect(result.nonBlocking).to.equal(true);
});
});
describe('given no SOCKS support', () => {
it('redirects connections to the proxy', async () => {
proxy = await SocksServer.start();
app = await HookedProcess.start({
PROXY_HOST: `'127.0.0.1'`,
PROXY_PORT: proxy.port.toString(),
PROXY_SUPPORTS_SOCKS5: 'false'
});
// No SOCKS handshake, so the proxy sees a raw connection rather than a request,
// but the connection itself lands on the proxy rather than the destination:
const result = await app.connect({ port: DESTINATION_PORT });
expect(result.result).to.equal(0);
expect(proxy.requests).to.deep.equal([]);
});
it('leaves connections to the proxy itself alone', async () => {
proxy = await SocksServer.start();
app = await HookedProcess.start({
PROXY_HOST: `'127.0.0.1'`,
PROXY_PORT: proxy.port.toString(),
PROXY_SUPPORTS_SOCKS5: 'true'
});
// Already going where we'd send it, so it must be left completely untouched -
// rewriting it would send our own SOCKS handshake into the proxy twice.
const result = await app.connect({
host: '127.0.0.1',
port: proxy.port,
nonBlocking: true
});
// A non-blocking connect() reports EINPROGRESS, which is exactly what proves we
// kept out of the way: had we touched it we'd have made it blocking and returned
// a result of our own.
expect(result.result).to.equal(-1);
expect(result.errno).to.equal(115); // EINPROGRESS
expect(result.nonBlocking).to.equal(true);
expect(proxy.requests).to.deep.equal([]);
});
it('leaves an exempted port unintercepted', async () => {
proxy = await SocksServer.start();
app = await HookedProcess.start({
PROXY_HOST: `'127.0.0.1'`,
PROXY_PORT: proxy.port.toString(),
IGNORED_NON_HTTP_PORTS: '[9]'
});
// Port 9 is exempted, so this goes to the real (dead) destination and fails,
// where without the exemption it would be redirected to our proxy and succeed:
const result = await app.connect({ host: '127.0.0.1', port: 9 });
expect(result.result).to.equal(-1);
});
});
});