UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

422 lines (371 loc) 16.4 kB
import * as _ from 'lodash'; import * as fs from 'fs/promises'; import * as tls from 'tls'; import url = require('url'); import { oneLine } from 'common-tags'; import CacheableLookup from 'cacheable-lookup'; import * as semver from 'semver'; import { CompletedBody, Headers } from '../types'; import { byteLength } from '../util/util'; import { asBuffer } from '../util/buffer-utils'; import { isLocalhostAddress, normalizeIP } from '../util/socket-util'; import { CachedDns, dnsLookup, DnsLookupFunction } from '../util/dns'; import { isMockttpBody, encodeBodyBuffer } from '../util/request-utils'; import { areFFDHECurvesSupported } from '../util/openssl-compat'; import { CallbackRequestResult, CallbackResponseMessageResult } from './requests/request-handler-definitions'; import { AbortError } from './requests/request-handlers'; import { CADefinition, PassThroughLookupOptions } from './passthrough-handling-definitions'; import { ErrorLike } from '../util/error'; // TLS settings for proxied connections, intended to avoid TLS fingerprint blocking // issues so far as possible, by closely emulating a Firefox Client Hello: const NEW_CURVES_SUPPORTED = areFFDHECurvesSupported(process.versions.openssl); const SSL_OP_LEGACY_SERVER_CONNECT = 1 << 2; const SSL_OP_TLSEXT_PADDING = 1 << 4; const SSL_OP_NO_ENCRYPT_THEN_MAC = 1 << 19; // All settings are designed to exactly match Firefox v103, since that's a good baseline // that seems to be widely accepted and is easy to emulate from Node.js. export const getUpstreamTlsOptions = (strictChecks: boolean): tls.SecureContextOptions => ({ ecdhCurve: [ 'X25519', 'prime256v1', // N.B. Equivalent to secp256r1 'secp384r1', 'secp521r1', ...(NEW_CURVES_SUPPORTED ? [ // Only available with OpenSSL v3+: 'ffdhe2048', 'ffdhe3072' ] : [] ) ].join(':'), sigalgs: [ 'ecdsa_secp256r1_sha256', 'ecdsa_secp384r1_sha384', 'ecdsa_secp521r1_sha512', 'rsa_pss_rsae_sha256', 'rsa_pss_rsae_sha384', 'rsa_pss_rsae_sha512', 'rsa_pkcs1_sha256', 'rsa_pkcs1_sha384', 'rsa_pkcs1_sha512', 'ECDSA+SHA1', 'rsa_pkcs1_sha1' ].join(':'), ciphers: [ 'TLS_AES_128_GCM_SHA256', 'TLS_CHACHA20_POLY1305_SHA256', 'TLS_AES_256_GCM_SHA384', 'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305', 'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-AES256-SHA', 'ECDHE-ECDSA-AES128-SHA', 'ECDHE-RSA-AES128-SHA', 'ECDHE-RSA-AES256-SHA', 'AES128-GCM-SHA256', 'AES256-GCM-SHA384', 'AES128-SHA', 'AES256-SHA', // This magic cipher is the very obtuse way that OpenSSL downgrades the overall // security level to allow various legacy settings, protocols & ciphers: ...(!strictChecks ? ['@SECLEVEL=0'] : [] ) ].join(':'), secureOptions: strictChecks ? SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC : SSL_OP_TLSEXT_PADDING | SSL_OP_NO_ENCRYPT_THEN_MAC | SSL_OP_LEGACY_SERVER_CONNECT, ...({ // Valid, but not included in Node.js TLS module types: requestOSCP: true } as any), // Trust intermediate certificates from the trusted CA list too. Without this, trusted CAs // are only used when they are self-signed root certificates. Seems to cause issues in Node v20 // in HTTP/2 tests, so disabled below the supported v22 version. allowPartialTrustChain: semver.satisfies(process.version, '>=22.9.0'), // Allow TLSv1, if !strict: minVersion: strictChecks ? tls.DEFAULT_MIN_VERSION : 'TLSv1', // Skip certificate validation entirely, if not strict: rejectUnauthorized: strictChecks, }); export async function getTrustedCAs( trustedCAs: Array<string | CADefinition> | undefined, additionalTrustedCAs: Array<CADefinition> | undefined ): Promise<Array<string> | undefined> { if (trustedCAs && additionalTrustedCAs?.length) { throw new Error(`trustedCAs and additionalTrustedCAs options are mutually exclusive`); } if (trustedCAs) { return Promise.all(trustedCAs.map((caDefinition) => getCA(caDefinition))); } if (additionalTrustedCAs) { const CAs = await Promise.all(additionalTrustedCAs.map((caDefinition) => getCA(caDefinition))); return tls.rootCertificates.concat(CAs); } } const getCA = async (caDefinition: string | CADefinition) => { return typeof caDefinition === 'string' ? caDefinition : 'certPath' in caDefinition ? await fs.readFile(caDefinition.certPath, 'utf8') // 'cert' in caDefinition : caDefinition.cert.toString('utf8') } // --- Various helpers for deriving parts of request/response data given partial overrides: --- /** * Takes a callback result and some headers, and returns a ready to send body, using the headers * (and potentially modifying them) to match the content type & encoding. */ export async function buildOverriddenBody( callbackResult: CallbackRequestResult | CallbackResponseMessageResult | void, headers: Headers ) { // Raw bodies are easy: use them as is. if (callbackResult?.rawBody) return callbackResult?.rawBody!; // In the json/body case, we need to get the body and transform it into a buffer // for consistent handling later, and encode it to match the headers. let replacementBody: string | Uint8Array | Buffer | CompletedBody | undefined; if (callbackResult?.json) { headers['content-type'] = 'application/json'; replacementBody = JSON.stringify(callbackResult?.json); } else { replacementBody = callbackResult?.body; } if (replacementBody === undefined) return replacementBody; let rawBuffer: Buffer; if (isMockttpBody(replacementBody)) { // It's our own bodyReader instance. That's not supposed to happen, but // it's ok, we just need to use the buffer data instead of the whole object rawBuffer = Buffer.from((replacementBody as CompletedBody).buffer); } else if (replacementBody === '') { // For empty bodies, it's slightly more convenient if they're truthy rawBuffer = Buffer.alloc(0); } else { rawBuffer = asBuffer(replacementBody); } return await encodeBodyBuffer(rawBuffer, headers); } /** * If you override some headers, they have implications for the effective URL we send the * request to. If you override that and the URL at the same time, it gets complicated. * * This method calculates the correct header value we should use: prioritising the header * value you provide, printing a warning if it's contradictory, or return the URL-inferred * value to override the header correctly if you didn't specify. */ function deriveUrlLinkedHeader( originalHeaders: Headers, replacementHeaders: Headers | undefined, headerName: 'host' | ':authority' | ':scheme', expectedValue: string // The inferred 'correct' value from the URL ) { const replacementValue = replacementHeaders?.[headerName]; if (replacementValue !== undefined) { if (replacementValue !== expectedValue && replacementValue === originalHeaders[headerName]) { // If you rewrite the URL-based header wrongly, by explicitly setting it to the // existing value, we accept it but print a warning. This would be easy to // do if you mutate the existing headers, for example, and ignore the host. console.warn(oneLine` Passthrough callback overrode the URL and the ${headerName} header with mismatched values, which may be a mistake. The URL implies ${expectedValue}, whilst the header was set to ${replacementValue}. `); } // Whatever happens, if you explicitly set a value, we use it. return replacementValue; } // If you didn't override the header at all, then we automatically ensure // the correct value is set automatically. return expectedValue; } /** * Autocorrect the host header only in the case that if you didn't explicitly * override it yourself for some reason (e.g. if you're testing bad behaviour). */ export function getHostAfterModification( reqUrl: string, originalHeaders: Headers, replacementHeaders: Headers | undefined ): string { return deriveUrlLinkedHeader( originalHeaders, replacementHeaders, 'host', url.parse(reqUrl).host! ); } export const OVERRIDABLE_REQUEST_PSEUDOHEADERS = [ ':authority', ':scheme' ] as const; /** * Automatically update the :scheme and :authority headers to match the updated URL, * as long as they weren't explicitly overriden themselves, in which case let them * be set to any invalid value you like (e.g. to send a request to one server but * pretend it was sent to another). */ export function getH2HeadersAfterModification( reqUrl: string, originalHeaders: Headers, replacementHeaders: Headers | undefined ): { [K in typeof OVERRIDABLE_REQUEST_PSEUDOHEADERS[number]]: string } { const parsedUrl = url.parse(reqUrl); return { ':scheme': deriveUrlLinkedHeader( originalHeaders, replacementHeaders, ':scheme', parsedUrl.protocol!.slice(0, -1) ), ':authority': deriveUrlLinkedHeader( originalHeaders, replacementHeaders, ':authority', parsedUrl.host! ) }; } // Helper to handle content-length nicely for you when rewriting requests with callbacks export function getContentLengthAfterModification( body: string | Uint8Array | Buffer, originalHeaders: Headers, replacementHeaders: Headers | undefined, mismatchAllowed: boolean = false ): string | undefined { // If there was a content-length header, it might now be wrong, and it's annoying // to need to set your own content-length override when you just want to change // the body. To help out, if you override the body but don't explicitly override // the (now invalid) content-length, then we fix it for you. if (!_.has(originalHeaders, 'content-length')) { // Nothing to override - use the replacement value, or undefined return (replacementHeaders || {})['content-length']; } if (!replacementHeaders) { // There was a length set, and you've provided a body but not changed it. // You probably just want to send this body and have it work correctly, // so we should fix the content length for you automatically. return byteLength(body).toString(); } // There was a content length before, and you're replacing the headers entirely const lengthOverride = replacementHeaders['content-length']?.toString(); // If you're setting the content-length to the same as the origin headers, even // though that's the wrong value, it *might* be that you're just extending the // existing headers, and you're doing this by accident (we can't tell for sure). // We use invalid content-length as instructed, but print a warning just in case. if ( lengthOverride === originalHeaders['content-length'] && lengthOverride !== byteLength(body).toString() && !mismatchAllowed // Set for HEAD responses ) { console.warn(oneLine` Passthrough modifications overrode the body and the content-length header with mismatched values, which may be a mistake. The body contains ${byteLength(body)} bytes, whilst the header was set to ${lengthOverride}. `); } return lengthOverride; } // Function to check if we should skip https errors for the current hostname and port, // based on the given config export function shouldUseStrictHttps( hostname: string, port: number, ignoreHostHttpsErrors: string[] | boolean ) { let skipHttpsErrors = false; if (ignoreHostHttpsErrors === true) { // Ignore cert errors if `ignoreHostHttpsErrors` is set to true, or skipHttpsErrors = true; } else if (Array.isArray(ignoreHostHttpsErrors) && ( // if the whole hostname or host+port is whitelisted _.includes(ignoreHostHttpsErrors, hostname) || _.includes(ignoreHostHttpsErrors, `${hostname}:${port}`) )) { skipHttpsErrors = true; } return !skipHttpsErrors; } export const getDnsLookupFunction = _.memoize((lookupOptions: PassThroughLookupOptions | undefined) => { if (!lookupOptions) { // By default, use 10s caching of hostnames, just to reduce the delay from // endlessly 10ms query delay for 'localhost' with every request. return new CachedDns(10000).lookup; } else { // Or if options are provided, use those to configure advanced DNS cases: const cacheableLookup = new CacheableLookup({ maxTtl: lookupOptions.maxTtl, errorTtl: lookupOptions.errorTtl, // As little caching of "use the fallback server" as possible: fallbackDuration: 0 }); if (lookupOptions.servers) { cacheableLookup.servers = lookupOptions.servers; } return cacheableLookup.lookup; } }); export async function getClientRelativeHostname( hostname: string | null, remoteIp: string | undefined, lookupFn: DnsLookupFunction ) { if (!hostname || !remoteIp || isLocalhostAddress(remoteIp)) return hostname; // Otherwise, we have a request from a different machine (or Docker container/VM/etc) and we need // to make sure that 'localhost' means _that_ machine, not ourselves. // This check must be run before req modifications. If a modification changes the address to localhost, // then presumably it really does mean *this* localhost. if ( // If the hostname is a known localhost address, we're done: isLocalhostAddress(hostname) || // Otherwise, we look up the IP, so we can accurately check for localhost-bound requests. This adds a little // delay, but since it's cached we save the equivalent delay in request lookup later, so it should be // effectively free. We ignore errors to delegate unresolvable etc to request processing later. isLocalhostAddress(await dnsLookup(lookupFn, hostname).catch(() => null)) ) { return normalizeIP(remoteIp) as string | null; // Note that we just redirect - we don't update the host header. From the POV of the target, it's still // 'localhost' traffic that should appear identical to normal. } else { return hostname; } } export function buildUpstreamErrorTags(e: ErrorLike) { const tags: string[] = []; // OpenSSL can throw all sorts of weird & wonderful errors here, and rarely exposes a // useful error code from them. To handle that, we try to detect the most common cases, // notable including the useless but common 'unsupported' error that covers all // OpenSSL-unsupported (e.g. legacy) configurations. if (!e.code && e.stack?.split('\n')[1]?.includes('node:internal/tls/secure-context')) { let tlsErrorTag: string; if (e.message === 'unsupported') { e.code = 'ERR_TLS_CONTEXT_UNSUPPORTED'; tlsErrorTag = 'context-unsupported'; e.message = 'Unsupported TLS configuration'; } else { e.code = 'ERR_TLS_CONTEXT_UNKNOWN'; tlsErrorTag = 'context-unknown'; e.message = `TLS context error: ${e.message}`; } tags.push(`passthrough-tls-error:${tlsErrorTag}`); } // All raw error codes are included in the tags: tags.push('passthrough-error:' + e.code); // We build tags for by SSL alerts, for each recognition elsewhere: const tlsAlertMatch = /SSL alert number (\d+)/.exec(e.message ?? ''); if (tlsAlertMatch) { tags.push('passthrough-tls-error:ssl-alert-' + tlsAlertMatch[1]); } if (e instanceof AbortError) { tags.push('passthrough-error:mockttp-abort') } return tags; }