UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

389 lines 20.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createComboServer = createComboServer; const _ = require("lodash"); const now = require("performance-now"); const net = require("net"); const tls = require("tls"); const semver = require("semver"); const destroyable_server_1 = require("destroyable-server"); const httpolyglot = require("@httptoolkit/httpolyglot"); const util_1 = require("@httptoolkit/util"); const read_tls_client_hello_1 = require("read-tls-client-hello"); const urlpattern_polyfill_1 = require("urlpattern-polyfill"); const certificates_1 = require("../util/certificates"); const server_utils_1 = require("../util/server-utils"); const url_1 = require("../util/url"); const socket_util_1 = require("../util/socket-util"); const socket_extensions_1 = require("../util/socket-extensions"); const socks_server_1 = require("./socks-server"); const socket_metadata_1 = require("../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 = tls.TLSSocket.prototype._init; tls.TLSSocket.prototype._init = function () { originalSocketInit.apply(this, arguments); const tlsSocket = this; const { _handle } = tlsSocket; if (!_handle) return; const loadSNI = _handle.oncertcb; _handle.oncertcb = function (info) { tlsSocket.servername = info.servername; tlsSocket[socket_extensions_1.InitialRemoteAddress] = tlsSocket.remoteAddress || // Normal case tlsSocket._parent?.remoteAddress || // For early failing sockets tlsSocket._handle?._parentWrap?.stream?.remoteAddress; // For HTTP/2 CONNECT tlsSocket[socket_extensions_1.InitialRemotePort] = tlsSocket.remotePort || tlsSocket._parent?.remotePort || tlsSocket._handle?._parentWrap?.stream?.remotePort; return loadSNI?.apply(this, arguments); }; }; // Takes an established TLS socket, calls the error listener if it's silently closed function ifTlsDropped(socket, errorCallback) { 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[socket_extensions_1.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; (0, util_1.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[socket_extensions_1.TlsSetupCompleted] = true; }) .catch(() => { // If TLS setup was confirmed in any way, we know we don't have a TLS error. if (socket[socket_extensions_1.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) { 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, cause) { const eventData = (0, socket_util_1.buildTlsSocketEventData)(socket); eventData.failureCause = cause; eventData.timingEvents.failureTimestamp = now(); return eventData; } ; // 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. async function createComboServer(options) { let server; let tlsServer = undefined; let socksServer = undefined; let unknownProtocolServer = undefined; if (options.https) { const ca = await (0, certificates_1.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 = 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, cb) => { 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 = (0, socks_server_1.buildSocksServer)(options.socks === true ? {} : options.socks); socksServer.on('socks-tcp-connect', (socket, address) => { const addressString = address.type === 'ipv4' ? `${address.ip}:${address.port}` : address.type === 'ipv6' ? `[${address.ip}]:${address.port}` : address.type === 'hostname' ? `${address.hostname}:${address.port}` : (0, util_1.unreachableCheck)(address); if (options.debug) console.log(`Proxying SOCKS TCP connection to ${addressString}`); socket[socket_extensions_1.SocketTimingInfo].tunnelSetupTimestamp = now(); socket[socket_extensions_1.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[socket_extensions_1.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 } = (0, url_1.getDestination)('unknown', tunnelAddress); // Has port, so no protocol required options.rawPassthroughListener(socket, hostname, port); } catch (e) { console.error('Unknown protocol server error', e); (0, socket_util_1.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._httpServer.requireHostHeader = false; server.on('connection', (socket) => { socket[socket_extensions_1.SocketTimingInfo] || (socket[socket_extensions_1.SocketTimingInfo] = (0, socket_util_1.buildSocketTimingInfo)()); // All sockets are initially marked as using unencrypted upstream connections. // If TLS is used, this is upgraded to 'true' by secureConnection below. socket[socket_extensions_1.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) => { const parentSocket = (0, socket_util_1.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[socket_extensions_1.TlsMetadata] ?? (socket[socket_extensions_1.TlsMetadata] = parentSocket[socket_extensions_1.TlsMetadata]); } else if (!socket[socket_extensions_1.SocketTimingInfo]) { socket[socket_extensions_1.SocketTimingInfo] = (0, socket_util_1.buildSocketTimingInfo)(); } socket[socket_extensions_1.SocketTimingInfo].tlsConnectedTimestamp = now(); socket[socket_extensions_1.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[socket_extensions_1.TlsSetupCompleted] = true; }); }); server.on('tlsClientError', (error, socket) => { 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, resOrSocket) { if (resOrSocket instanceof net.Socket) { handleH1Connect(req, resOrSocket); } else { handleH2Connect(req, resOrSocket); } }); function handleH1Connect(req, 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[socket_extensions_1.SocketTimingInfo].tunnelSetupTimestamp = now(); socket[socket_extensions_1.LastTunnelAddress] = connectUrl; if (req.headers['proxy-authorization']) { socket[socket_extensions_1.SocketMetadata] = (0, socket_metadata_1.getSocketMetadataFromProxyAuth)(socket, req.headers['proxy-authorization']); } server.emit('connection', socket); }); } function handleH2Connect(req, res) { 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[socket_extensions_1.LastTunnelAddress] = connectUrl; if (req.headers['proxy-authorization']) { res.stream[socket_extensions_1.SocketMetadata] = (0, socket_metadata_1.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 (0, destroyable_server_1.makeDestroyable)(server); } const SOCKET_METADATA = [ 'localAddress', 'localPort', 'remoteAddress', 'remotePort', socket_extensions_1.SocketTimingInfo, socket_extensions_1.SocketMetadata, socket_extensions_1.LastTunnelAddress ]; function inheritSocketDetails(source, target) { // 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 })))); for (let fieldName of SOCKET_METADATA) { if (target[fieldName] === undefined) { if (typeof source[fieldName] === 'object') { target[fieldName] = _.cloneDeep(source[fieldName]); } else { target[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, passthroughList, interceptOnlyList, passthroughListener) { if (passthroughList && interceptOnlyList) { throw new Error('Cannot use both tlsPassthrough and tlsInterceptOnly options at the same time.'); } const passThroughPatterns = passthroughList?.map(({ hostname }) => new urlpattern_polyfill_1.URLPattern(`https://${hostname}`)) ?? []; const interceptOnlyPatterns = interceptOnlyList?.map(({ hostname }) => new urlpattern_polyfill_1.URLPattern(`https://${hostname}`)); const tlsConnectionListener = server.listeners('connection')[0]; server.removeListener('connection', tlsConnectionListener); server.on('connection', async (socket) => { try { const helloData = await (0, read_tls_client_hello_1.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; if (socket[socket_extensions_1.LastTunnelAddress]) { upstreamDestination = (0, url_1.getDestination)('https', socket[socket_extensions_1.LastTunnelAddress]); } socket[socket_extensions_1.TlsMetadata] = { sniHostname, clientAlpn: helloData.alpnProtocols, ja3Fingerprint: (0, read_tls_client_hello_1.calculateJa3FromFingerprintData)(helloData.fingerprintData), ja4Fingerprint: (0, read_tls_client_hello_1.calculateJa4FromHelloData)(helloData) }; if ((0, server_utils_1.shouldPassThrough)(upstreamDestination?.hostname, passThroughPatterns, interceptOnlyPatterns)) { passthroughListener(socket, upstreamDestination.hostname, upstreamDestination.port); return; // Do not continue with TLS } else if ((0, server_utils_1.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 read_tls_client_hello_1.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.message ?? e}`); } } // Didn't match a passthrough hostname - continue with TLS setup tlsConnectionListener.call(server, socket); }); } //# sourceMappingURL=http-combo-server.js.map