UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

501 lines (429 loc) 21.5 kB
import _ = require('lodash'); import now = require('performance-now'); import net = require('net'); import tls = require('tls'); import http = require('http'); import http2 = require('http2'); import * as semver from 'semver'; import { makeDestroyable, DestroyableServer } from 'destroyable-server'; import * as httpolyglot from '@httptoolkit/httpolyglot'; import { delay, unreachableCheck } from '@httptoolkit/util'; import { calculateJa3FromFingerprintData, calculateJa4FromHelloData, NonTlsError, readTlsClientHello } from 'read-tls-client-hello'; import { URLPattern } from "urlpattern-polyfill"; import { Destination, TlsHandshakeFailure } from '../types'; import { getCA } from '../util/certificates'; import { shouldPassThrough } from '../util/server-utils'; import { getDestination } from '../util/url'; import { getParentSocket, buildSocketTimingInfo, buildTlsSocketEventData, resetOrDestroy } from '../util/socket-util'; import { SocketIsh, InitialRemoteAddress, InitialRemotePort, SocketTimingInfo, LastTunnelAddress, LastHopEncrypted, TlsMetadata, TlsSetupCompleted, SocketMetadata, } from '../util/socket-extensions'; import { MockttpHttpsOptions } from '../mockttp'; import { buildSocksServer, SocksServerOptions, SocksTcpAddress } from './socks-server'; import { getSocketMetadataFromProxyAuth } from '../util/socket-metadata'; // Hardcore monkey-patching: force TLSSocket to link servername & remoteAddress to // sockets as soon as they're available, without waiting for the handshake to fully // complete, so we can easily access them if the handshake fails. const originalSocketInit = (<any>tls.TLSSocket.prototype)._init; (<any>tls.TLSSocket.prototype)._init = function () { originalSocketInit.apply(this, arguments); const tlsSocket: tls.TLSSocket = this; const { _handle } = tlsSocket; if (!_handle) return; const loadSNI = _handle.oncertcb; _handle.oncertcb = function (info: any) { tlsSocket.servername = info.servername; tlsSocket[InitialRemoteAddress] = tlsSocket.remoteAddress || // Normal case tlsSocket._parent?.remoteAddress || // For early failing sockets tlsSocket._handle?._parentWrap?.stream?.remoteAddress; // For HTTP/2 CONNECT tlsSocket[InitialRemotePort] = tlsSocket.remotePort || tlsSocket._parent?.remotePort || tlsSocket._handle?._parentWrap?.stream?.remotePort; return loadSNI?.apply(this, arguments as any); }; }; // Takes an established TLS socket, calls the error listener if it's silently closed function ifTlsDropped(socket: tls.TLSSocket, errorCallback: () => void) { new Promise((resolve, reject) => { // If you send data, you trust the TLS connection socket.once('data', resolve); // If you silently close it very quicky, you probably don't trust us socket.once('error', reject); socket.once('close', reject); socket.once('end', reject); // Some clients open later-unused TLS connections for connection pools, preconnect, etc. // Even if these are shut later on, that doesn't mean they're are rejected connections. // To differentiate the two cases, we consider connections OK after waiting 10x longer // than the initial TLS handshake for an unhappy disconnection. const timing = socket[SocketTimingInfo]; const tlsSetupDuration = timing ? timing.tlsConnectedTimestamp! - (timing.tunnelSetupTimestamp! || timing.initialSocketTimestamp) : 0; const maxTlsRejectionTime = !Object.is(tlsSetupDuration, NaN) ? Math.max(tlsSetupDuration * 10, 100) // Ensure a sensible minimum : 2000; delay(maxTlsRejectionTime).then(resolve); }) .then(() => { // Mark the socket as having completed TLS setup - this ensures that future // errors fire as client errors, not TLS setup errors. socket[TlsSetupCompleted] = true; }) .catch(() => { // If TLS setup was confirmed in any way, we know we don't have a TLS error. if (socket[TlsSetupCompleted]) return; // To get here, the socket must have connected & done the TLS handshake, but then // closed/ended without ever sending any data. We can fairly confidently assume // in that case that it's rejected our certificate. errorCallback(); }); } function getCauseFromError(error: Error & { code?: string }) { const cause = ( /alert certificate/.test(error.message) || /alert bad certificate/.test(error.message) || error.code === 'ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE' || /alert unknown ca/.test(error.message) ) // The client explicitly told us it doesn't like the certificate ? 'cert-rejected' : /no shared cipher/.test(error.message) // The client refused to negotiate a cipher. Probably means it didn't like the // cert so refused to continue, but it could genuinely not have a shared cipher. ? 'no-shared-cipher' : (/ECONNRESET/.test(error.message) || error.code === 'ECONNRESET') // The client sent no TLS alert, it just hard RST'd the connection ? 'reset' : error.code === 'ERR_TLS_HANDSHAKE_TIMEOUT' ? 'handshake-timeout' : 'unknown'; // Something else. if (cause === 'unknown') console.log('Unknown TLS error:', error); return cause; } function buildTlsError( socket: tls.TLSSocket, cause: TlsHandshakeFailure['failureCause'] ): TlsHandshakeFailure { const eventData = buildTlsSocketEventData(socket) as TlsHandshakeFailure; eventData.failureCause = cause; eventData.timingEvents.failureTimestamp = now(); return eventData; } export interface ComboServerOptions { debug: boolean; https: MockttpHttpsOptions | undefined; http2: boolean | 'fallback'; socks: boolean | SocksServerOptions; passthroughUnknownProtocols: boolean; requestListener: (req: http.IncomingMessage, res: http.ServerResponse) => void; tlsClientErrorListener: (socket: tls.TLSSocket, req: TlsHandshakeFailure) => void; tlsPassthroughListener: (socket: net.Socket, hostname: string, port?: number) => void; rawPassthroughListener: (socket: net.Socket, hostname: string, port?: number) => void; }; // The low-level server that handles all the sockets & TLS. The server will correctly call the // given handler for both HTTP & HTTPS direct connections, or connections when used as an // either HTTP or HTTPS proxy, all on the same port. export async function createComboServer(options: ComboServerOptions): Promise<DestroyableServer<net.Server>> { let server: net.Server; let tlsServer: tls.Server | undefined = undefined; let socksServer: net.Server | undefined = undefined; let unknownProtocolServer: net.Server | undefined = undefined; if (options.https) { const ca = await getCA(options.https); const defaultCert = await ca.generateCertificate(options.https.defaultDomain ?? 'localhost'); const serverProtocolPreferences = options.http2 === true ? ['h2', 'http/1.1', 'http 1.1'] // 'http 1.1' is non-standard, but used by https-proxy-agent : options.http2 === 'fallback' ? ['http/1.1', 'http 1.1', 'h2'] // options.http2 === false: : ['http/1.1', 'http 1.1']; const ALPNOption: tls.TlsOptions = semver.satisfies(process.version, '>=20.4.0') ? { // In modern Node (20+), ALPNProtocols will reject unknown protocols. To allow those (so we can // at least read the request, and hopefully handle HTTP-like cases - not uncommon) we use the new // ALPNCallback feature instead, which lets us dynamically accept unrecognized protocols: ALPNCallback: ({ protocols: clientProtocols }) => { const preferredProtocol = serverProtocolPreferences.find(p => clientProtocols.includes(p)); // Wherever possible, we tell the client to use our preferred protocol if (preferredProtocol) return preferredProtocol; // If the client only offers protocols that we don't understand, shrug and accept: else return clientProtocols[1]; } } : { // In Node versions without ALPNCallback, we just set preferences directly: ALPNProtocols: serverProtocolPreferences } tlsServer = tls.createServer({ key: defaultCert.key, cert: defaultCert.cert, ca: [defaultCert.ca], ...ALPNOption, ...(options.https?.tlsServerOptions || {}), SNICallback: async (domain: string, cb: Function) => { if (options.debug) console.log(`Generating certificate for ${domain}`); try { const generatedCert = await ca.generateCertificate(domain); cb(null, tls.createSecureContext({ key: generatedCert.key, cert: generatedCert.cert, ca: generatedCert.ca })); } catch (e) { console.error('Cert generation error', e); cb(e); } } }); analyzeAndMaybePassThroughTls( tlsServer, options.https.tlsPassthrough, options.https.tlsInterceptOnly, options.tlsPassthroughListener ); } if (options.socks) { socksServer = buildSocksServer(options.socks === true ? {} : options.socks); socksServer.on('socks-tcp-connect', (socket: net.Socket, address: SocksTcpAddress) => { const addressString = address.type === 'ipv4' ? `${address.ip}:${address.port}` : address.type === 'ipv6' ? `[${address.ip}]:${address.port}` : address.type === 'hostname' ? `${address.hostname}:${address.port}` : unreachableCheck(address) if (options.debug) console.log(`Proxying SOCKS TCP connection to ${addressString}`); socket[SocketTimingInfo]!.tunnelSetupTimestamp = now(); socket[LastTunnelAddress] = addressString; // Put the socket back into the server, so we can handle the data within: server.emit('connection', socket); }); } if (options.passthroughUnknownProtocols) { unknownProtocolServer = net.createServer((socket) => { const tunnelAddress = socket[LastTunnelAddress]; try { if (!tunnelAddress) { server.emit('clientError', new Error('Unknown protocol without destination'), socket); return; } if (!tunnelAddress.includes(':')) { // Both CONNECT & SOCKS require a port, so this shouldn't happen server.emit('clientError', new Error('Unknown protocol without destination port'), socket); return; } const { hostname, port } = getDestination('unknown', tunnelAddress); // Has port, so no protocol required options.rawPassthroughListener(socket, hostname, port); } catch (e) { console.error('Unknown protocol server error', e); resetOrDestroy(socket); } }); } server = httpolyglot.createServer({ tls: tlsServer, socks: socksServer, unknownProtocol: unknownProtocolServer }, options.requestListener); // In Node v20, this option was added, rejecting all requests with no host header. While that's good, in // our case, we want to handle the garbage requests too, so we disable it: (server as any)._httpServer.requireHostHeader = false; server.on('connection', (socket: net.Socket | http2.ServerHttp2Stream) => { socket[SocketTimingInfo] ||= buildSocketTimingInfo(); // All sockets are initially marked as using unencrypted upstream connections. // If TLS is used, this is upgraded to 'true' by secureConnection below. socket[LastHopEncrypted] = false; // For actual sockets, set NODELAY to avoid any buffering whilst streaming. This is // off by default in Node HTTP, but likely to be enabled soon & is default in curl. if ('setNoDelay' in socket) socket.setNoDelay(true); }); server.on('secureConnection', (socket: tls.TLSSocket) => { const parentSocket = getParentSocket(socket); if (parentSocket) { // Sometimes wrapper TLS sockets created by the HTTP/2 server don't include the // underlying socket details, so it's better to make sure we copy them up. inheritSocketDetails(parentSocket, socket); // With TLS metadata, we only propagate directly from parent sockets, not through // CONNECT etc - we only want it if the final hop is TLS, previous values don't matter. socket[TlsMetadata] ??= parentSocket[TlsMetadata]; } else if (!socket[SocketTimingInfo]) { socket[SocketTimingInfo] = buildSocketTimingInfo(); } socket[SocketTimingInfo]!.tlsConnectedTimestamp = now(); socket[LastHopEncrypted] = true; ifTlsDropped(socket, () => { options.tlsClientErrorListener(socket, buildTlsError(socket, 'closed')); }); }); // Mark HTTP/2 sockets as set up once we receive a first settings frame. This always // happens immediately after the connection preface, as long as the connection is OK. server!.on('session', (session) => { session.once('remoteSettings', () => { (session.socket as tls.TLSSocket)[TlsSetupCompleted] = true; }); }); server.on('tlsClientError', (error: Error, socket: tls.TLSSocket) => { options.tlsClientErrorListener(socket, buildTlsError(socket, getCauseFromError(error))); }); // If the server receives a HTTP/HTTPS CONNECT request, Pretend to tunnel, then just re-handle: server.addListener('connect', function ( req: http.IncomingMessage | http2.Http2ServerRequest, resOrSocket: net.Socket | http2.Http2ServerResponse ) { if (resOrSocket instanceof net.Socket) { handleH1Connect(req as http.IncomingMessage, resOrSocket); } else { handleH2Connect(req as http2.Http2ServerRequest, resOrSocket); } }); function handleH1Connect(req: http.IncomingMessage, socket: net.Socket) { // Clients may disconnect at this point (for all sorts of reasons), but here // nothing else is listening, so we need to catch errors on the socket: socket.once('error', (e) => { if (options.debug) { console.log('Error on client socket', e); } }); const connectUrl = req.url || req.headers['host']; if (!connectUrl) { // If we can't work out where to go, send an error. socket.write('HTTP/' + req.httpVersion + ' 400 Bad Request\r\n\r\n', 'utf-8'); return; } if (options.debug) console.log(`Proxying HTTP/1 CONNECT to ${connectUrl}`); socket.write('HTTP/' + req.httpVersion + ' 200 OK\r\n\r\n', 'utf-8', () => { socket[SocketTimingInfo]!.tunnelSetupTimestamp = now(); socket[LastTunnelAddress] = connectUrl; if (req.headers['proxy-authorization']) { socket[SocketMetadata] = getSocketMetadataFromProxyAuth(socket, req.headers['proxy-authorization']); } server.emit('connection', socket); }); } function handleH2Connect(req: http2.Http2ServerRequest, res: http2.Http2ServerResponse) { const connectUrl = req.headers[':authority']; if (!connectUrl) { // If we can't work out where to go, send an error. res.writeHead(400, {}); res.end(); return; } if (options.debug) console.log(`Proxying HTTP/2 CONNECT to ${connectUrl}`); // Send a 200 OK response, and start the tunnel: res.writeHead(200, {}); inheritSocketDetails(res.socket, res.stream); res.stream[LastTunnelAddress] = connectUrl; if (req.headers['proxy-authorization']) { res.stream[SocketMetadata] = getSocketMetadataFromProxyAuth(res.stream, req.headers['proxy-authorization']); } // When layering HTTP/2 on JS streams, we have to make sure the JS stream won't autoclose // when the other side does, because the upper HTTP/2 layers want to handle shutdown, so // they end up trying to write a GOAWAY at the same time as the lower stream shuts down, // and we get assertion errors in Node v16.7+. if (res.socket.constructor.name.includes('JSStreamSocket')) { res.socket.allowHalfOpen = true; } server.emit('connection', res.stream); } return makeDestroyable(server); } const SOCKET_METADATA = [ 'localAddress', 'localPort', 'remoteAddress', 'remotePort', SocketTimingInfo, SocketMetadata, LastTunnelAddress ] as const; function inheritSocketDetails( source: SocketIsh<typeof SOCKET_METADATA[number]>, target: SocketIsh<typeof SOCKET_METADATA[number]> ) { // Update the target socket(-ish) with the assorted metadata from the source socket, // iff the target has no details of its own. // Make sure all properties are writable - HTTP/2 streams notably try to block this. Object.defineProperties(target, _.zipObject( SOCKET_METADATA, _.range(SOCKET_METADATA.length).map(() => ({ writable: true })) ) as PropertyDescriptorMap); for (let fieldName of SOCKET_METADATA) { if (target[fieldName] === undefined) { if (typeof source[fieldName] === 'object') { (target as any)[fieldName] = _.cloneDeep(source[fieldName]); } else { (target as any)[fieldName] = source[fieldName]; } } } } /** * Takes tls passthrough configuration (may be empty) and reconfigures a given TLS server so that all * client hellos are parsed, matching requests are passed to the given passthrough listener (without * continuing setup) and client hello metadata is attached to all sockets. */ function analyzeAndMaybePassThroughTls( server: tls.Server, passthroughList: Required<MockttpHttpsOptions>['tlsPassthrough'] | undefined, interceptOnlyList: Required<MockttpHttpsOptions>['tlsInterceptOnly'] | undefined, passthroughListener: (socket: net.Socket, hostname: string, port?: number) => void ) { if (passthroughList && interceptOnlyList){ throw new Error('Cannot use both tlsPassthrough and tlsInterceptOnly options at the same time.'); } const passThroughPatterns = passthroughList?.map(({ hostname }) => new URLPattern(`https://${hostname}`)) ?? []; const interceptOnlyPatterns = interceptOnlyList?.map(({ hostname }) => new URLPattern(`https://${hostname}`)); const tlsConnectionListener = server.listeners('connection')[0] as (socket: net.Socket) => {}; server.removeListener('connection', tlsConnectionListener); server.on('connection', async (socket: net.Socket) => { try { const helloData = await readTlsClientHello(socket); const sniHostname = helloData.serverName; // SNI is a good clue for where the request is headed, but an explicit proxy address (via // CONNECT or SOCKS) is even better. Note that this may be a hostname or IPv4/6 address: let upstreamDestination: Destination | undefined; if (socket[LastTunnelAddress]) { upstreamDestination = getDestination('https', socket[LastTunnelAddress]); } socket[TlsMetadata] = { sniHostname, clientAlpn: helloData.alpnProtocols, ja3Fingerprint: calculateJa3FromFingerprintData(helloData.fingerprintData), ja4Fingerprint: calculateJa4FromHelloData(helloData) }; if (shouldPassThrough(upstreamDestination?.hostname, passThroughPatterns, interceptOnlyPatterns)) { passthroughListener(socket, upstreamDestination.hostname, upstreamDestination.port); return; // Do not continue with TLS } else if (shouldPassThrough(sniHostname, passThroughPatterns, interceptOnlyPatterns)) { passthroughListener(socket, sniHostname!); // Can't guess the port - not included in SNI return; // Do not continue with TLS } } catch (e) { if (!(e instanceof NonTlsError)) { // Don't even warn for non-TLS traffic console.warn(`TLS client hello data not available for TLS connection from ${ socket.remoteAddress ?? 'unknown address' }: ${(e as Error).message ?? e}`); } } // Didn't match a passthrough hostname - continue with TLS setup tlsConnectionListener.call(server, socket); }); }