UNPKG

mockttp-mvs

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

298 lines 14.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createComboServer = void 0; const _ = require("lodash"); const now = require("performance-now"); const net = require("net"); const tls = require("tls"); const destroyable_server_1 = require("destroyable-server"); const httpolyglot = require("@httptoolkit/httpolyglot"); const read_tls_client_hello_1 = require("read-tls-client-hello"); const tls_1 = require("../util/tls"); const util_1 = require("../util/util"); const socket_util_1 = require("../util/socket-util"); // 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.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); }; }; // 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.__timingInfo; 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.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) { 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.buildSocketEventData)(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, requestListener, tlsClientErrorListener, tlsPassthroughListener) { let server; if (!options.https) { server = httpolyglot.createServer(requestListener); } else { const ca = await (0, tls_1.getCA)(options.https); const defaultCert = ca.generateCertificate(options.https.defaultDomain ?? 'localhost'); const tlsServer = tls.createServer({ key: defaultCert.key, cert: defaultCert.cert, ca: [defaultCert.ca], ALPNProtocols: options.http2 === true ? ['h2', 'http/1.1'] : options.http2 === 'fallback' ? ['http/1.1', 'h2'] // false : ['http/1.1'], SNICallback: (domain, cb) => { if (options.debug) console.log(`Generating certificate for ${domain}`); try { const generatedCert = 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 ?? [], tlsPassthroughListener); server = httpolyglot.createServer(tlsServer, requestListener); } server.on('connection', (socket) => { socket.__timingInfo = socket.__timingInfo || (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.__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. copyAddressDetails(parentSocket, socket); copyTimingDetails(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 ?? (socket.__tlsMetadata = parentSocket.__tlsMetadata); } else if (!socket.__timingInfo) { socket.__timingInfo = (0, socket_util_1.buildSocketTimingInfo)(); } socket.__timingInfo.tlsConnectedTimestamp = now(); socket.__lastHopEncrypted = true; ifTlsDropped(socket, () => { 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.tlsSetupCompleted = true; }); }); server.on('tlsClientError', (error, socket) => { 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) => 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.__timingInfo.tunnelSetupTimestamp = now(); socket.__lastHopConnectAddress = connectUrl; 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, {}); copyAddressDetails(res.socket, res.stream); copyTimingDetails(res.socket, res.stream); res.stream.__lastHopConnectAddress = connectUrl; // 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); } exports.createComboServer = createComboServer; const SOCKET_ADDRESS_METADATA_FIELDS = [ 'localAddress', 'localPort', 'remoteAddress', 'remotePort', '__lastHopConnectAddress' ]; // Update the target socket(-ish) with the address details from the source socket, // iff the target has no details of its own. function copyAddressDetails(source, target) { Object.defineProperties(target, _.zipObject(SOCKET_ADDRESS_METADATA_FIELDS, _.range(SOCKET_ADDRESS_METADATA_FIELDS.length).map(() => ({ writable: true })))); SOCKET_ADDRESS_METADATA_FIELDS.forEach((fieldName) => { if (target[fieldName] === undefined) { target[fieldName] = source[fieldName]; } }); } function copyTimingDetails(source, target) { if (!target.__timingInfo) { // Clone timing info, don't copy it - child sockets get their own independent timing stats target.__timingInfo = Object.assign({}, source.__timingInfo); } } /** * Takes a tls passthrough list (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, passthroughListener) { const hostnames = passthroughList.map(({ hostname }) => 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 [connectHostname, connectPort] = socket.__lastHopConnectAddress?.split(':') ?? []; const sniHostname = helloData.serverName; socket.__tlsMetadata = { sniHostname, connectHostname, connectPort, clientAlpn: helloData.alpnProtocols, ja3Fingerprint: (0, read_tls_client_hello_1.calculateJa3FromFingerprintData)(helloData.fingerprintData) }; if (connectHostname && hostnames.includes(connectHostname)) { const upstreamPort = connectPort ? parseInt(connectPort, 10) : undefined; passthroughListener(socket, connectHostname, upstreamPort); return; // Do not continue with TLS } else if (sniHostname && hostnames.includes(sniHostname)) { 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