UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

379 lines (324 loc) 14 kB
import { Buffer } from 'buffer'; import * as net from 'net'; import * as _ from 'lodash'; import { resetOrDestroy } from '../util/socket-util'; import { SocketMetadata } from '../util/socket-extensions'; import { getSocketMetadata } from '../util/socket-metadata'; export interface SocksServerOptions { /** * An array of authentication methods to be used for incoming SOCKS5 * connections, in preference order. This defaults to `['no-auth']`. * * If `no-auth` is not included, all SOCKS4 connections will be * rejected (as they do not support authentication). * * The supported methods are: * - `no-auth`: Standard no-authentication-required method (0x00) * - `custom-metadata`: Custom method (0xDA), which doesn't authenticate * but allows the client to send 2-byte-length-prefixed arbitrary JSON * metadata to the server, which will be associated with all * requests sent on this connection. The server will respond with * 0x05 0x00 for 'success' after the metadata is received, or * 0x05 0x01 for a general failure, or 0x05 0xDA plus a 2-byte-length-prefixed * JSON error with a `message` field in other cases. The only currently * exposed metadata is the `tags` field, if provided here. The `mockttpParams` * field in this metadata is reserved for future use. * - `user-password-metadata`: Use standard username/password authentication * method (0x02) to smuggle metadata - this does not really authenticate the * user. The username must be `metadata` and the password * must be a JSON object of up to 255 chars in total. All other usernames * & passwords will be rejected. This metadata is used just like * `custom-metadata` but this is compatible with existing SOCKS clients. * This is still less preferable due to possible client confusion and * the 255 character limit. */ authMethods?: Array<keyof typeof AUTH_METHODS>; } export type SocksTcpAddress = | { type: 'hostname', hostname: string; port: number } | { type: 'ipv4', ip: string; port: number } | { type: 'ipv6', ip: string; port: number }; interface SocksServer extends net.Server { on(event: 'socks-tcp-connect', cb: (socket: net.Socket, address: SocksTcpAddress) => void): this; // Need to include all other net events we might want to use, or we lose the overload types: on(event: 'connection', listener: (socket: net.Socket) => void): this; on(event: 'close', listener: () => void): this; on(event: 'error', listener: (err: Error) => void): this; on(event: string, listener: (...args: any[]) => void): this; } const AUTH_METHODS = { 'no-auth': { id: 0x0, handler: handleNoAuth }, 'user-password-metadata': { id: 0x2, handler: handleUsernamePasswordMetadata }, 'custom-metadata': { id: 0xDA, handler: handleCustomMetadata } } as const; const AUTH_METHOD_KEYS = Object.keys(AUTH_METHODS) as Array<keyof typeof AUTH_METHODS>; export function buildSocksServer(options: SocksServerOptions): SocksServer { const authMethods = options.authMethods ?? ['no-auth']; if (authMethods.length === 0) throw new Error('At least one SOCKS auth method must be specified'); if (authMethods.some(method => !AUTH_METHOD_KEYS.includes(method))) { throw new Error(`Invalid SOCKS auth method specified. Supported methods are: ${AUTH_METHOD_KEYS.join(', ')}`); } return net.createServer(handleSocksConnect); async function handleSocksConnect(this: net.Server, socket: net.Socket): Promise<void> { const server = this; // Until we pass this socket onwards, we handle (and drop) any errors on it: socket.on('error', ignoreError); try { const firstByte = await readBytes(socket, 1);; const version = firstByte[0]; if (version === 0x04) { await handleSocksV4(socket, (address: SocksTcpAddress) => { socket.removeListener('error', ignoreError); server.emit('socks-tcp-connect', socket, address); }); } else if (version === 0x05) { await handleSocksV5(socket, (address: SocksTcpAddress) => { socket.removeListener('error', ignoreError); server.emit('socks-tcp-connect', socket, address); }); } else { // Should never happen, since this is sniffed by Httpolyglot, but just in case: resetOrDestroy(socket); } } catch (err) { // We log but otherwise ignore failures, e.g. if the client closes the // connection after sending just half a message. console.warn(`Failed to process SOCKS connection`, err); socket.destroy(); } } async function handleSocksV4(socket: net.Socket, cb: (address: SocksTcpAddress) => void) { const buffer = await readBytes(socket, 7); // N.b version already read if (!authMethods.includes('no-auth')) { // We only support no-auth for now, so reject anything else return writeS4Rejection(socket); } const command = buffer[0]; if (command !== 0x01) { // Only CONNECT is supported, reject anything else return writeS4Rejection(socket); } const port = buffer.readUInt16BE(1); const ip = buffer.subarray(3, 7).join('.'); await readUntilNullByte(socket); // Read (and ignore) the user id if (ip.startsWith('0.0.0')) { // SOCKSv4a - the hostname will be sent (null-terminated) after the user id: const domain = await readUntilNullByte(socket); socket.write(Buffer.from([ 0x00, 0x5A, // Success // Omit the bound address & port here. It doesn't make sense for // our use case, and clients generally shouldn't need this info. 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ])); cb({ type: 'hostname', hostname: domain.toString('utf8'), port }); } else { // SOCKSv4 - we have an IPv4 address and we're good to go: socket.write(Buffer.from([ 0x00, 0x5A, // Success // Omit the bound address & port here. It doesn't make sense for // our use case, and clients generally shouldn't need this info. 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ])); cb({ type: 'ipv4', ip: ip, port }); } } async function handleSocksV5(socket: net.Socket, cb: (address: SocksTcpAddress) => void) { const buffer = await readBytes(socket, 1); // N.b version already read const authMethodsCount = buffer[0]; const clientMethods = await readBytes(socket, authMethodsCount); const selectedAuthMethodId = authMethods.find(methodKey => clientMethods.includes(AUTH_METHODS[methodKey].id) ); if (selectedAuthMethodId === undefined) { // Reject any connections that don't match our supported auth methods: return socket.end(Buffer.from([ 0x05, // Version 0xFF, // No acceptable auth methods ])); } const authMethod = AUTH_METHODS[selectedAuthMethodId]; // Confirm the selected auth method: socket.write(Buffer.from([ 0x05, // Version authMethod.id ])); try { const success = await authMethod.handler(socket); if (!success) return; } catch (err) { console.warn(`SOCKS auth failed`, err); // Not actually totally clear what to return for an unknown error like this // but this should always make it clear that we're done in any case: return socket.end(Buffer.from([ 0x05, 0x01 // General failure ])); } // Ok - we're authenticated, now negotiate the connection itself: const [ version, command, _reserved, addressType ] = await readBytes(socket, 4); if (version !== 0x05) { // Should never happen, but just in case return writeS5ConnFailure(socket, 0x01); // General error } if (command !== 0x01) { // Only CONNECT is supported for now, reject anything else return writeS5ConnFailure(socket, 0x07); // General error } let address: SocksTcpAddress; if (addressType === 0x1) { // IPv4 const addressData = await readBytes(socket, 6); const ip = addressData.subarray(0, 4).join('.'); const port = addressData.readUInt16BE(4); address = { type: 'ipv4', ip, port }; } else if (addressType === 0x3) { // DNS const nameLength = await readBytes(socket, 1); const nameAndPortData = await readBytes(socket, nameLength[0] + 2); const name = nameAndPortData.subarray(0, nameLength[0]).toString('utf8'); const port = nameAndPortData.readUInt16BE(nameLength[0]); address = { type: 'hostname', hostname: name, port }; } else if (addressType === 0x4) { // IPv6 const addressData = await readBytes(socket, 18); const ipv6Bytes = addressData.subarray(0, 16); const hextets = []; for (let i = 0; i < ipv6Bytes.length; i += 2) { const hextet = ((ipv6Bytes[i] << 8) | ipv6Bytes[i + 1]).toString(16); hextets.push(hextet); } const ip = hextets.join(':'); const port = addressData.readUInt16BE(16); address = { type: 'ipv6', ip, port }; } else { return writeS5ConnFailure(socket, 0x08); // Unsupported address type } socket.write(Buffer.from([ 0x05, // Version 0x00, // Success 0x00, // Reserved 0x01, // IPv4 bind address 0x00, 0x00, 0x00, 0x00, // Blank bind address 0x00, 0x00 // Blank bind port ])); cb(address); } } async function handleNoAuth() { return true; } async function handleCustomMetadata(socket: net.Socket) { const length = (await readBytes(socket, 2)).readUint16BE(); const metadata = await readBytes(socket, length); const metadataString = metadata.toString('utf8'); try { socket[SocketMetadata] = getSocketMetadata(socket[SocketMetadata], metadataString); } catch (e) { const errorData = Buffer.from(JSON.stringify({ message: 'Invalid JSON' })); const errorResponse = Buffer.alloc(4 + errorData.byteLength); errorResponse.writeUInt8(0x05, 0); errorResponse.writeUInt8(0xDA, 1); errorResponse.writeUInt16BE(errorData.byteLength, 2); errorData.copy(errorResponse, 4); socket.end(errorResponse); return false; } socket.write(Buffer.from([ 0x05, // Version 0x00 // Success ])); return true; } async function handleUsernamePasswordMetadata(socket: net.Socket) { const versionAndLength = await readBytes(socket, 2); const usernameLength = versionAndLength.readUint8(1); const username = await readBytes(socket, usernameLength); const passwordLength = await readBytes(socket, 1); const password = await readBytes(socket, passwordLength[0]); if (username.toString('utf8') !== 'metadata') { socket.end(Buffer.from([ 0x05, 0x01 // Generic rejection ])); return false; } try { socket[SocketMetadata] = getSocketMetadata(socket[SocketMetadata], password); } catch (e) { socket.end(Buffer.from([ 0x05, 0x02 // Rejected (with a different error code to distinguish this case) ])); return false; } socket.write(Buffer.from([ 0x05, // Version 0x00 // Success ])); return true; } async function readBytes(socket: net.Socket, length?: number | undefined): Promise<Buffer> { const buffer = socket.read(length); if (buffer === null) { return new Promise<Buffer>((resolve, reject) => { socket.once('readable', () => resolve(readBytes(socket, length))); socket.once('close', () => reject(new Error('Socket closed'))); socket.once('error', reject); }); } else if (length !== undefined && buffer.byteLength != length) { throw new Error(`Socket closed before we received ${length} bytes`); } return buffer; } async function readUntilNullByte(socket: net.Socket) { let buffers: Buffer[] = []; while (true) { const data = await readBytes(socket); const endOfIdIndex = data.indexOf(0x00); if (endOfIdIndex !== -1) { const remainingData = data.subarray(endOfIdIndex + 1); if (remainingData.length > 0) socket.unshift(remainingData); buffers.push(data.subarray(0, endOfIdIndex)); break; } else { buffers.push(data); } } return Buffer.concat(buffers); } const writeS4Rejection = (socket: net.Socket) => { socket.end(Buffer.from([ 0x00, 0x5B, // Generic rejection 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 ])); }; const writeS5ConnFailure = (socket: net.Socket, errorCode: number) => { socket.end(Buffer.from([ 0x05, // Version errorCode, // Failure code 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // Blank bind address ])); }; function ignoreError() {}