diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..0f7e6b3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.git +.gitignore +dist +coverage +.nyc_output +*.log +.DS_Store +.idea +.vscode +docs +README.md + diff --git a/README.md b/README.md index f16e624c..98f91241 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ the world's most popular web craling library for Node.js. The proxy-chain package currently supports HTTP/SOCKS forwarding and HTTP CONNECT tunneling to forward arbitrary protocols such as HTTPS or FTP ([learn more](https://blog.apify.com/tunneling-arbitrary-protocols-over-http-proxy-with-static-ip-address-b3a2222191ff)). The HTTP CONNECT tunneling also supports the SOCKS protocol. Also, proxy-chain only supports the Basic [Proxy-Authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authorization). -## Run a simple HTTP/HTTPS proxy server +## Run a simple HTTP proxy server ```javascript const ProxyChain = require('proxy-chain'); @@ -30,7 +30,7 @@ server.listen(() => { }); ``` -## Run a HTTP/HTTPS proxy server with credentials and upstream proxy +## Run a HTTP proxy server with credentials and upstream proxy ```javascript const ProxyChain = require('proxy-chain'); @@ -110,6 +110,108 @@ server.on('requestFailed', ({ request, error }) => { }); ``` +## Run simple HTTPS proxy server + +This example demonstrates how to create an HTTPS proxy server with a self-signed certificate. +The HTTPS proxy server works identically to the HTTP version but with TLS encryption. + +```javascript +// examples/https_proxy_server.js +const { Server, generateCertificate } = require('proxy-chain'); + +(async () => { + // Generate a self-signed certificate for development/testing + // In production, you should use a proper certificate from a Certificate Authority + console.log('Generating self-signed certificate...'); + const { key, cert } = generateCertificate({ + commonName: 'localhost', + validityDays: 365, + organization: 'Development', + }); + + console.log('Certificate generated successfully!'); + + // Create an HTTPS proxy server + const server = new Server({ + // Main difference between 'http' and 'https' is additional event listening: + // + // http + // -> listen for 'connection' events to track raw TCP sockets + // + // https: + // -> listen for 'securedConnection' events (insted of 'connection') to track only post-TLS-handshake sockets + // -> additionally listen for 'tlsClientError' events to handle TLS handshake errors + // + // Default value is 'http' + serverType: 'https', + + // Provide the TLS certificate and private key + httpsOptions: { + key, + cert, + }, + + // Port where the server will listen + port: 8443, + + // Enable verbose logging to see what's happening + verbose: true, + + // Optional: Add authentication and upstream proxy configuration + prepareRequestFunction: ({ username, hostname, port }) => { + console.log(`Request to ${hostname}:${port} from user: ${username || 'anonymous'}`); + + // Allow the request + return {}; + }, + }); + + // Handle failed HTTP/HTTPS requests + server.on('requestFailed', ({ request, error }) => { + console.log(`Request ${request.url} failed`); + console.error(error); + }); + + // Handle TLS handshake errors + server.on('tlsError', ({ error, socket }) => { + console.error(`TLS error from ${socket.remoteAddress}: ${error.message}`); + }); + + // Emitted when HTTP/HTTPS connection is closed + server.on('connectionClosed', ({ connectionId, stats }) => { + console.log(`Connection ${connectionId} closed`); + console.dir(stats); + }); + + // Start the server + await server.listen(); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down server...'); + await server.close(true); + console.log('Server closed.'); + process.exit(0); + }); + + // Keep the server running + await new Promise(() => { }); +})(); +``` + +Run server: + +```bash +node examples/https_proxy_server.js +``` + +Send request via proxy: + +```bash +curl --proxy-insecure -x https://localhost:8443 -k https://example.com +``` + +Note: flag `--proxy-insecure` is used since our certificate is self-signed. ## Use custom HTTP agents for connection pooling You can provide custom HTTP/HTTPS agents to enable connection pooling and reuse with upstream proxies. This is particularly useful for maintaining sticky IP addresses or reducing connection overhead: @@ -156,6 +258,20 @@ SOCKS protocol is supported for versions 4 and 5, specifically: `['socks', 'sock You can use an `upstreamProxyUrl` like `socks://username:password@proxy.example.com:1080`. +## Emitted Events + +The `Server` class emits the following events that you can listen to for monitoring and debugging purposes: + +| Event Name | Description | Event Data | +|------------|-------------|------------| +| `connectionClosed` | Emitted when an HTTP/HTTPS connection to the proxy server is closed. Useful for monitoring traffic and cleaning up resources. | `{ connectionId: number, stats: ConnectionStats }` | +| `requestFailed` | Emitted when an HTTP/HTTPS request fails with an unexpected error (not a `RequestError`). Useful for error monitoring and logging. | `{ error: Error, request: http.IncomingMessage }` | +| `tlsError` | Emitted when TLS handshake fails (HTTPS servers only). Useful for monitoring SSL/TLS issues. The server handles the error internally. | `{ error: Error, socket: tls.TLSSocket }` | +| `tunnelConnectResponded` | Emitted when a CONNECT tunnel to an upstream proxy is successfully established. Useful for accessing response headers from the upstream proxy. | `{ proxyChainId: number, response: http.IncomingMessage, socket: net.Socket, head: Buffer, customTag?: unknown }` | +| `tunnelConnectFailed` | Emitted when a CONNECT tunnel to an upstream proxy fails (receives non-200 status code). Useful for monitoring upstream proxy issues. | `{ proxyChainId: number, response: http.IncomingMessage, socket: net.Socket, head: Buffer, customTag?: unknown }` | + +All events are optional to handle - the proxy server will function correctly without any event listeners. + ## Error status codes The `502 Bad Gateway` HTTP status code is not comprehensive enough. Therefore, the server may respond with `590-599` instead: diff --git a/examples/https_proxy_server.js b/examples/https_proxy_server.js new file mode 100644 index 00000000..43736560 --- /dev/null +++ b/examples/https_proxy_server.js @@ -0,0 +1,75 @@ +/* eslint-disable no-console */ +const { Server, generateCertificate } = require('..'); + +// This example demonstrates how to create an HTTPS proxy server with a self-signed certificate. +// The HTTPS proxy server works identically to the HTTP version but with TLS encryption. + +(async () => { + // Generate a self-signed certificate for development/testing + // In production, you should use a proper certificate from a Certificate Authority + console.log('Generating self-signed certificate...'); + const { key, cert } = generateCertificate({ + commonName: 'localhost', + validityDays: 365, + organization: 'Development', + }); + + console.log('Certificate generated successfully!'); + + // Create an HTTPS proxy server + const server = new Server({ + // Use HTTPS instead of HTTP + serverType: 'https', + + // Provide the TLS certificate and private key + httpsOptions: { + key, + cert, + }, + + // Port where the server will listen + port: 8443, + + // Enable verbose logging to see what's happening + verbose: true, + + // Optional: Add authentication and upstream proxy configuration + prepareRequestFunction: ({ username, hostname, port }) => { + console.log(`Request to ${hostname}:${port} from user: ${username || 'anonymous'}`); + + // Allow the request + return {}; + }, + }); + + // Handle failed HTTP/HTTPS requests + server.on('requestFailed', ({ request, error }) => { + console.log(`Request ${request.url} failed`); + console.error(error); + }); + + // Handle TLS handshake errors + server.on('tlsError', ({ error, socket }) => { + console.error(`TLS error from ${socket.remoteAddress}: ${error.message}`); + }); + + // Emitted when HTTP/HTTPS connection is closed + server.on('connectionClosed', ({ connectionId, stats }) => { + console.log(`Connection ${connectionId} closed`); + console.dir(stats); + }); + + // Start the server + await server.listen(); + + // Handle graceful shutdown + process.on('SIGINT', async () => { + console.log('\nShutting down server...'); + await server.close(true); + console.log('Server closed.'); + process.exit(0); + }); + + // Keep the server running + await new Promise(() => { }); +})(); diff --git a/package.json b/package.json index 28bcb49e..ab562184 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxy-chain", - "version": "2.6.0", + "version": "2.7.0", "description": "Node.js implementation of a proxy server (think Squid) with support for SSL, authentication, upstream proxy chaining, and protocol tunneling.", "main": "dist/index.js", "keywords": [ diff --git a/src/index.ts b/src/index.ts index f945ef87..ab3fdf05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './request_error'; export * from './server'; export * from './utils/redact_url'; +export * from './utils/generate_certificate'; export * from './anonymize_proxy'; export * from './tcp_tunnel_tools'; diff --git a/src/server.ts b/src/server.ts index f584caaa..8c17e60c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,8 +3,9 @@ import { Buffer } from 'node:buffer'; import type dns from 'node:dns'; import { EventEmitter } from 'node:events'; import http from 'node:http'; -import type https from 'node:https'; +import https from 'node:https'; import type net from 'node:net'; +import type tls from 'node:tls'; import { URL } from 'node:url'; import util from 'node:util'; @@ -19,7 +20,7 @@ import type { HandlerOpts as ForwardOpts } from './forward'; import { forward } from './forward'; import { forwardSocks } from './forward_socks'; import { RequestError } from './request_error'; -import type { Socket } from './socket'; +import type { Socket, TLSSocket } from './socket'; import { badGatewayStatusCodes } from './statuses'; import { getTargetStats } from './utils/count_target_bytes'; import { nodeify } from './utils/nodeify'; @@ -41,10 +42,45 @@ export const SOCKS_PROTOCOLS = ['socks:', 'socks4:', 'socks4a:', 'socks5:', 'soc const DEFAULT_AUTH_REALM = 'ProxyChain'; const DEFAULT_PROXY_SERVER_PORT = 8000; +const HTTPS_DEFAULTS = { + minVersion: 'TLSv1.2', // Disable TLS 1.0 and 1.1 (deprecated, insecure) + maxVersion: 'TLSv1.3', // Enable modern TLS 1.3 + // Strong cipher suites (TLS 1.3 and TLS 1.2) + ciphers: [ + // TLS 1.3 ciphers (always enabled with TLS 1.3) + 'TLS_AES_128_GCM_SHA256', + 'TLS_AES_256_GCM_SHA384', + 'TLS_CHACHA20_POLY1305_SHA256', + // TLS 1.2 ciphers (strong only) + 'ECDHE-RSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES256-GCM-SHA384', + ].join(':'), +} as const; + +/** + * Connection statistics for bandwidth tracking and billing. + * + * Byte Semantics by Server Type: + * + * - HTTP servers: Source bytes represent application-layer traffic only + * - HTTPS servers: Source bytes include TLS handshake overhead and encryption overhead (total consumed bandwidth) + * - Typical TLS overhead: 10-20% for TLS 1.2, 5-15% for TLS 1.3 + * - Higher overhead for short-lived connections (handshake-dominated) + * - Lower overhead for long-lived connections (handshake amortized) + * + * Target bytes: Always represent application-layer traffic only (both HTTP and HTTPS) + * + * Failed handshakes: Only successful TLS connections are tracked here. + * Failed handshakes emit 'tlsError' event for monitoring. + */ export type ConnectionStats = { + // Bytes sent to client (HTTP: app only, HTTPS: total including TLS overhead) srcTxBytes: number; + // Bytes received from client (HTTP: app only, HTTPS: total including TLS overhead) srcRxBytes: number; + // Bytes sent to target (always application-layer, null if no target connection) trgTxBytes: number | null; + // Bytes received from target (always application-layer, null if no target connection) trgRxBytes: number | null; }; @@ -96,10 +132,32 @@ export type PrepareRequestFunctionResult = { type Promisable = T | Promise; export type PrepareRequestFunction = (opts: PrepareRequestFunctionOpts) => Promisable; +interface ServerOptionsBase { + port?: number; + host?: string; + prepareRequestFunction?: PrepareRequestFunction; + verbose?: boolean; + authRealm?: unknown; +} + +export interface HttpServerOptions extends ServerOptionsBase { + serverType?: 'http'; +} + +export interface HttpsServerOptions extends ServerOptionsBase { + serverType: 'https'; + httpsOptions: https.ServerOptions; +} + +export type ServerOptions = HttpServerOptions | HttpsServerOptions; + /** * Represents the proxy server. * It emits the 'requestFailed' event on unexpected request errors, with the following parameter `{ error, request }`. * It emits the 'connectionClosed' event when connection to proxy server is closed, with parameter `{ connectionId, stats }`. + * It emits the 'tlsError' event on TLS handshake failures (HTTPS servers only), with parameter `{ error, socket }`. + * It emits the 'tlsOverheadUnavailable' event when TLS overhead tracking is unavailable (HTTPS servers only), + * with parameter `{ connectionId, reason, hasParent, parentType }`. */ export class Server extends EventEmitter { port: number; @@ -112,7 +170,9 @@ export class Server extends EventEmitter { verbose: boolean; - server: http.Server; + server: http.Server | https.Server; + + serverType: 'http' | 'https'; lastHandlerId: number; @@ -124,6 +184,9 @@ export class Server extends EventEmitter { * Initializes a new instance of Server class. * @param options * @param [options.port] Port where the server will listen. By default 8000. + * @param [options.serverType] Type of server to create: 'http' or 'https'. By default 'http'. + * @param [options.httpsOptions] HTTPS server options (required when serverType is 'https'). + * Accepts standard Node.js https.ServerOptions including key, cert, ca, passphrase, etc. * @param [options.prepareRequestFunction] Custom function to authenticate proxy requests, * provide URL to upstream proxy or potentially provide a function that generates a custom response to HTTP requests. * It accepts a single parameter which is an object: @@ -154,13 +217,7 @@ export class Server extends EventEmitter { * @param [options.authRealm] Realm used in the Proxy-Authenticate header and also in the 'Server' HTTP header. By default it's `ProxyChain`. * @param [options.verbose] If true, the server will output logs */ - constructor(options: { - port?: number, - host?: string, - prepareRequestFunction?: PrepareRequestFunction, - verbose?: boolean, - authRealm?: unknown, - } = {}) { + constructor(options: ServerOptions = {}) { super(); if (options.port === undefined || options.port === null) { @@ -174,11 +231,43 @@ export class Server extends EventEmitter { this.authRealm = options.authRealm || DEFAULT_AUTH_REALM; this.verbose = !!options.verbose; - this.server = http.createServer(); + // Keep legacy behavior (http) as default behavior + this.serverType = options.serverType === 'https' ? 'https' : 'http'; + + if (options.serverType === 'https') { + if (!options.httpsOptions) { + throw new Error('httpsOptions is required when serverType is "https"'); + } + + // Apply secure TLS defaults (user options can override) + const secureDefaults: https.ServerOptions = { + ...HTTPS_DEFAULTS, + honorCipherOrder: true, + ...options.httpsOptions, + }; + + this.server = https.createServer(secureDefaults); + } else { + this.server = http.createServer(); + } + + // Attach common event handlers (same for both HTTP and HTTPS) this.server.on('clientError', this.onClientError.bind(this)); this.server.on('request', this.onRequest.bind(this)); this.server.on('connect', this.onConnect.bind(this)); - this.server.on('connection', this.onConnection.bind(this)); + + // Attach connection tracking based on server type + // CRITICAL: Only listen to ONE connection event to avoid double registration + if (this.serverType === 'https') { + // For HTTPS: Track only post-TLS-handshake sockets (secureConnection) + // This ensures we track the TLS-wrapped socket with correct bytesRead/bytesWritten + this.server.on('secureConnection', this.onConnection.bind(this)); + // Handle TLS handshake errors to prevent server crashes + this.server.on('tlsClientError', this.onTLSClientError.bind(this)); + } else { + // For HTTP: Track raw TCP sockets (connection) + this.server.on('connection', this.onConnection.bind(this)); + } this.lastHandlerId = 0; this.stats = { @@ -200,14 +289,38 @@ export class Server extends EventEmitter { onClientError(err: NodeJS.ErrnoException, socket: Socket): void { this.log(socket.proxyChainId, `onClientError: ${err}`); + // HTTP protocol error occurred after TLS handshake succeeded (in case HTTPS server is used) // https://nodejs.org/api/http.html#http_event_clienterror if (err.code === 'ECONNRESET' || !socket.writable) { return; } + // Can send HTTP response because HTTP protocol layer is active this.sendSocketResponse(socket, 400, {}, 'Invalid request'); } + /** + * Handles TLS handshake errors for HTTPS servers. + * Without this handler, unhandled TLS errors can crash the server. + * Common errors: ECONNRESET, ERR_SSL_SSLV3_ALERT_CERTIFICATE_UNKNOWN, + * ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION, ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE + */ + onTLSClientError(err: NodeJS.ErrnoException, tlsSocket: tls.TLSSocket): void { + const connectionId = (tlsSocket as TLSSocket).proxyChainId; + this.log(connectionId, `TLS handshake failed: ${err.message}`); + + // If connection already reset or socket not writable, nothing to do + if (err.code === 'ECONNRESET' || !tlsSocket.writable) { + return; + } + + // TLS handshake failed before HTTP, cannot send HTTP response + tlsSocket.destroy(err); + + // Emit event for user monitoring/metrics + this.emit('tlsError', { error: err, socket: tlsSocket }); + } + /** * Assigns a unique ID to the socket and keeps the register up to date. * Needed for abrupt close of the server. @@ -248,11 +361,33 @@ export class Server extends EventEmitter { // We need to consume socket errors, because the handlers are attached asynchronously. // See https://github.com/apify/proxy-chain/issues/53 socket.on('error', (err) => { - // Handle errors only if there's no other handler - if (this.listenerCount('error') === 1) { + // Prevent duplicate error handling for the same socket + if (socket.proxyChainErrorHandled) return; + socket.proxyChainErrorHandled = true; + + // Log errors only if this is the only error handler on the socket + // If other handlers exist (from handler functions), they'll handle logging + if (socket.listenerCount('error') === 1) { this.log(socket.proxyChainId, `Source socket emitted error: ${err.stack || err}`); } }); + + // Check once per connection for socket._parent availability. + if (this.serverType === 'https') { + const rawSocket = socket._parent; + if (!rawSocket || typeof rawSocket.bytesWritten !== 'number' || typeof rawSocket.bytesRead !== 'number') { + // Emit event for observability purposes that TLS overhead for https is unavailable. + this.emit('tlsOverheadUnavailable', { + connectionId: socket.proxyChainId, + reason: 'raw_socket_missing', + hasParent: !!rawSocket, + parentType: rawSocket?.constructor?.name, + }); + socket.tlsOverheadAvailable = false; + } else { + socket.tlsOverheadAvailable = true; + } + } } /** @@ -620,16 +755,33 @@ export class Server extends EventEmitter { const socket = this.connections.get(connectionId); if (!socket) return undefined; + // Socket contains application bytes only. + let srcTxBytes = socket.bytesWritten ?? 0; + let srcRxBytes = socket.bytesRead ?? 0; + + if (this.serverType === 'https' && socket.tlsOverheadAvailable) { + /* eslint no-underscore-dangle: ["error", { "allow": ["_parent"] }] */ + // Access underlying raw socket to get total bytes (app + TLS overhead). + const rawSocket = socket._parent; + if (rawSocket && typeof rawSocket.bytesWritten === 'number' && typeof rawSocket.bytesRead === 'number') { + if (rawSocket.bytesWritten >= socket.bytesWritten && rawSocket.bytesRead >= socket.bytesRead) { + srcTxBytes = rawSocket.bytesWritten; + srcRxBytes = rawSocket.bytesRead; + } else { + // This should never happen, log for debugging. + this.log(connectionId, `Warning: TLS overhead count error.`); + } + } + } + const targetStats = getTargetStats(socket); - const result = { - srcTxBytes: socket.bytesWritten, - srcRxBytes: socket.bytesRead, - trgTxBytes: targetStats.bytesWritten, - trgRxBytes: targetStats.bytesRead, + return { + srcTxBytes, // HTTP: app only, HTTPS: total (app + TLS overhead) + srcRxBytes, // HTTP: app only, HTTPS: total (app + TLS overhead) + trgTxBytes: targetStats?.bytesWritten, + trgRxBytes: targetStats?.bytesRead, }; - - return result; } /** diff --git a/src/socket.ts b/src/socket.ts index 4b139470..425d8072 100644 --- a/src/socket.ts +++ b/src/socket.ts @@ -1,7 +1,17 @@ import type net from 'node:net'; import type tls from 'node:tls'; -type AdditionalProps = { proxyChainId?: number }; +type AdditionalProps = { + proxyChainId?: number; + proxyChainErrorHandled?: boolean; + tlsOverheadAvailable?: boolean; + /** + * Contains net.Socket (parent) socket for tls.TLSSocket and should be `undefined` for net.Socket. + * It's not officially documented in Node.js docs. + * See https://github.com/nodejs/node/blob/v25.0.0/lib/internal/tls/wrap.js#L939 + */ + _parent?: Socket | undefined; +}; export type Socket = net.Socket & AdditionalProps; export type TLSSocket = tls.TLSSocket & AdditionalProps; diff --git a/src/utils/count_target_bytes.ts b/src/utils/count_target_bytes.ts index 983021b6..79c352d4 100644 --- a/src/utils/count_target_bytes.ts +++ b/src/utils/count_target_bytes.ts @@ -10,6 +10,7 @@ type Stats = { bytesWritten: number | null, bytesRead: number | null }; /** * Socket object extended with previous read and written bytes. * Necessary due to target socket re-use. + * Works with both net.Socket and tls.TLSSocket (since TLSSocket extends net.Socket). */ export type SocketWithPreviousStats = net.Socket & { previousBytesWritten?: number, previousBytesRead?: number }; @@ -22,7 +23,7 @@ interface Extras { // @ts-expect-error TS is not aware that `source` is used in the assertion. // eslint-disable-next-line @typescript-eslint/no-empty-function -function typeSocket(source: unknown): asserts source is net.Socket & Extras {} +function typeSocket(source: unknown): asserts source is net.Socket & Extras { } export const countTargetBytes = ( source: net.Socket, diff --git a/src/utils/generate_certificate.ts b/src/utils/generate_certificate.ts new file mode 100644 index 00000000..148968c8 --- /dev/null +++ b/src/utils/generate_certificate.ts @@ -0,0 +1,123 @@ +import { execSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export interface GenerateCertificateOptions { + /** + * Common Name for the certificate (e.g., 'localhost', '*.example.com') + * @default 'localhost' + */ + commonName?: string; + + /** + * Number of days the certificate is valid for + * @default 365 + */ + validityDays?: number; + + /** + * Key size in bits + * @default 2048 + */ + keySize?: number; + + /** + * Organization name + * @default 'Development' + */ + organization?: string; + + /** + * Country code (2 letters) + * @default 'US' + */ + countryCode?: string; +} + +export interface GeneratedCertificate { + /** + * Private key in PEM format + */ + key: string; + + /** + * Certificate in PEM format + */ + cert: string; +} + +/** + * Generates a self-signed certificate for development/testing purposes. + * Requires OpenSSL to be installed on the system. + * + * @param options - Configuration options for certificate generation + * @returns Object containing the private key and certificate in PEM format + * @throws Error if OpenSSL is not available or certificate generation fails + * + * @example + * ```typescript + * import { generateCertificate, Server } from 'proxy-chain'; + * + * // Generate a self-signed certificate + * const { key, cert } = generateCertificate({ + * commonName: 'localhost', + * validityDays: 365, + * }); + * + * // Create HTTPS proxy server + * const server = new Server({ + * port: 8443, + * serverType: 'https', + * httpsOptions: { key, cert }, + * }); + * ``` + */ +export function generateCertificate(options: GenerateCertificateOptions = {}): GeneratedCertificate { + const { + commonName = 'localhost', + validityDays = 365, + keySize = 2048, + organization = 'Development', + countryCode = 'US', + } = options; + + // Check if OpenSSL is available + try { + execSync('openssl version', { stdio: 'pipe' }); + } catch { + throw new Error( + 'OpenSSL is not available. Please install OpenSSL to generate certificates.\n' + + 'macOS: brew install openssl\n' + + 'Ubuntu/Debian: apt-get install openssl\n' + + 'Windows: https://slproweb.com/products/Win32OpenSSL.html', + ); + } + + // Create temporary directory for certificate generation + const tempDir = mkdtempSync(join(tmpdir(), 'proxy-chain-cert-')); + + try { + const keyPath = join(tempDir, 'key.pem'); + const certPath = join(tempDir, 'cert.pem'); + + // Build subject string + const subject = `/C=${countryCode}/O=${organization}/CN=${commonName}`; + + // Generate private key and certificate in one command + const command = `openssl req -x509 -newkey rsa:${keySize} -nodes -keyout "${keyPath}" -out "${certPath}" -days ${validityDays} -subj "${subject}"`; + + execSync(command, { stdio: 'pipe' }); + + // Read generated files + const key = readFileSync(keyPath, 'utf8'); + const cert = readFileSync(certPath, 'utf8'); + + return { key, cert }; + } catch (error) { + throw new Error(`Failed to generate certificate: ${(error as Error).message}`); + } finally { + // Clean up temporary directory + rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/test/Dockerfile b/test/Dockerfile index 9f92b4f1..d8aad04b 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -1,6 +1,6 @@ FROM node:18.20.8-bookworm@sha256:c6ae79e38498325db67193d391e6ec1d224d96c693a8a4d943498556716d3783 -RUN apt-get update && apt-get install -y --no-install-recommends chromium=142.0.7444.134-1~deb12u1 \ +RUN apt-get update && apt-get install -y --no-install-recommends chromium \ && rm -rf /var/lib/apt/lists/* ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true @@ -8,15 +8,10 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium WORKDIR /home/node -COPY .. . +COPY --chown=node:node package*.json ./ +RUN npm --quiet set progress=false && npm install --no-optional +COPY --chown=node:node . . -RUN npm --quiet set progress=false \ - && npm install --no-optional \ - && echo "Installed NPM packages:" \ - && npm list || true \ - && echo "Node.js version:" \ - && node --version \ - && echo "NPM version:" \ - && npm --version +USER node -CMD ["npm", "test"] +ENTRYPOINT [ "npm", "test", "--" ] diff --git a/test/dns_lookup.js b/test/dns_lookup.js new file mode 100644 index 00000000..c42a00dd --- /dev/null +++ b/test/dns_lookup.js @@ -0,0 +1,423 @@ +const dns = require('dns'); +const fs = require('fs'); +const path = require('path'); +const portastic = require('portastic'); +const { expect } = require('chai'); +const { Server } = require('../src/index'); +const { TargetServer } = require('./utils/target_server'); +const http = require('http'); +const net = require('net'); + +const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key')); +const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt')); + +describe('Custom DNS Resolver (dnsLookup)', function () { + this.timeout(10000); + + let proxyServer; + let targetServer; + let upstreamProxyServer; + let proxyPort; + let targetPort; + let upstreamProxyPort; + + let dnsLookupCallCount = 0; + let lastResolvedHostname = null; + + const createCustomDnsLookup = (resolveToLocalhost = true) => { + return (hostname, options, callback) => { + dnsLookupCallCount++; + lastResolvedHostname = hostname; + + if (hostname === 'custom-resolved.test' && resolveToLocalhost) { + return callback(null, '127.0.0.1', 4); + } + + if (hostname === 'dns-error.test') { + const err = new Error('getaddrinfo ENOTFOUND dns-error.test'); + err.code = 'ENOTFOUND'; + err.hostname = hostname; + return callback(err); + } + + if (hostname === 'ipv6-resolved.test') { + return callback(null, '::1', 6); + } + + // Fallback to real DNS for localhost and actual target servers + return dns.lookup(hostname, options, callback); + }; + }; + + beforeEach(async () => { + dnsLookupCallCount = 0; + lastResolvedHostname = null; + + const ports = await portastic.find({ min: 50000, max: 51000, retrieve: 3 }); + [proxyPort, targetPort, upstreamProxyPort] = ports; + }); + + afterEach(async () => { + if (proxyServer) { + await proxyServer.close(true); + proxyServer = null; + } + if (upstreamProxyServer) { + await upstreamProxyServer.close(true); + upstreamProxyServer = null; + } + if (targetServer) { + await targetServer.close(); + targetServer = null; + } + }); + + it('uses custom dnsLookup for HTTP target through HTTP proxy', async () => { + targetServer = new TargetServer({ port: targetPort }); + await targetServer.listen(); + + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + dnsLookup: createCustomDnsLookup(), + }), + verbose: false, + }); + await proxyServer.listen(); + + // Make request through proxy using forward.ts handler + const response = await new Promise((resolve, reject) => { + const req = http.request({ + hostname: 'localhost', + port: proxyPort, + path: `http://localhost:${targetPort}/test`, + method: 'GET', + }, resolve); + req.on('error', reject); + req.end(); + }); + + expect(response.statusCode).to.equal(200); + expect(dnsLookupCallCount).to.be.equal(1); + }); + + it('uses custom dnsLookup for CONNECT tunnel through HTTP proxy', async () => { + targetServer = new TargetServer({ port: targetPort }); + await targetServer.listen(); + + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + dnsLookup: createCustomDnsLookup(), + }), + verbose: false, + }); + await proxyServer.listen(); + + // Establish CONNECT tunnel using direct.ts handler + const tunnelEstablished = await new Promise((resolve, reject) => { + const socket = net.connect({ + host: 'localhost', + port: proxyPort, + }); + + socket.on('connect', () => { + socket.write(`CONNECT localhost:${targetPort} HTTP/1.1\r\nHost: localhost:${targetPort}\r\n\r\n`); + }); + + let responseData = ''; + socket.on('data', (data) => { + responseData += data.toString(); + if (responseData.includes('\r\n\r\n')) { + const success = responseData.includes('200 Connection Established'); + socket.destroy(); + resolve(success); + } + }); + + socket.on('error', reject); + }); + + expect(tunnelEstablished).to.equal(true); + expect(dnsLookupCallCount).to.be.equal(1); + }); + + it('uses custom dnsLookup for CONNECT tunnel through HTTPS proxy', async () => { + targetServer = new TargetServer({ port: targetPort }); + await targetServer.listen(); + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: sslKey, + cert: sslCrt, + }, + prepareRequestFunction: () => ({ + dnsLookup: createCustomDnsLookup(), + }), + verbose: false, + }); + await proxyServer.listen(); + + // Establish CONNECT tunnel through HTTPS proxy + const tls = require('tls'); + const tunnelEstablished = await new Promise((resolve, reject) => { + const socket = tls.connect({ + host: 'localhost', + port: proxyPort, + rejectUnauthorized: false, + }); + + socket.on('secureConnect', () => { + socket.write(`CONNECT localhost:${targetPort} HTTP/1.1\r\nHost: localhost:${targetPort}\r\n\r\n`); + }); + + let responseData = ''; + socket.on('data', (data) => { + responseData += data.toString(); + if (responseData.includes('\r\n\r\n')) { + const success = responseData.includes('200 Connection Established'); + socket.destroy(); + resolve(success); + } + }); + + socket.on('error', reject); + }); + + expect(tunnelEstablished).to.equal(true); + expect(dnsLookupCallCount).to.be.equal(1); + }); + + it('handles DNS lookup errors for HTTP requests without upstream (returns 404)', async () => { + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + dnsLookup: createCustomDnsLookup(), + }), + verbose: false, + }); + await proxyServer.listen(); + + // Request to hostname that will fail DNS lookup + const response = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: proxyPort, + path: 'http://dns-error.test/test', + method: 'GET', + }, resolve); + req.on('error', () => { + // Ignore connection errors + }); + req.end(); + }); + + // Per statuses.ts:142, forward handler without upstream returns 404 for ENOTFOUND + expect(response.statusCode).to.equal(404); + expect(dnsLookupCallCount).to.be.equal(1); + }); + + it('handles DNS lookup errors when resolving upstream proxy hostname (returns 593)', async () => { + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + // Upstream proxy hostname that will fail DNS resolution + upstreamProxyUrl: 'http://dns-error.test:8080', + dnsLookup: createCustomDnsLookup(), + }), + verbose: false, + }); + await proxyServer.listen(); + + const response = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: proxyPort, + path: 'http://example.com/test', + method: 'GET', + }, resolve); + req.on('error', () => { + // Ignore errors + }); + req.end(); + }); + + // DNS error when connecting to upstream proxy returns 593 + expect(response.statusCode).to.equal(593); + expect(dnsLookupCallCount).to.be.equal(1); + }); + + it('handles DNS errors in CONNECT tunnels (connection fails)', async () => { + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + dnsLookup: createCustomDnsLookup(), + }), + verbose: false, + }); + await proxyServer.listen(); + + // Try to establish CONNECT tunnel to host with DNS error + const connectionFailed = await new Promise((resolve) => { + const socket = net.connect({ + host: 'localhost', + port: proxyPort, + }); + + socket.on('connect', () => { + socket.write('CONNECT dns-error.test:443 HTTP/1.1\r\nHost: dns-error.test:443\r\n\r\n'); + }); + + let responseData = ''; + socket.on('data', (data) => { + responseData += data.toString(); + if (responseData.includes('\r\n\r\n')) { + const isError = !responseData.includes('200 Connection Established'); + socket.destroy(); + resolve(isError); + } + }); + + socket.on('error', () => { + resolve(true); // Connection failed as expected + }); + + socket.on('close', () => { + if (!responseData) { + resolve(true); // Connection closed without response + } + }); + + // Timeout if no response + setTimeout(() => { + socket.destroy(); + resolve(true); + }, 3000); + }); + + expect(connectionFailed).to.equal(true); + expect(dnsLookupCallCount).to.be.equal(1); + }); + + it('uses custom DNS with upstream proxy chaining', async () => { + targetServer = new TargetServer({ port: targetPort }); + await targetServer.listen(); + + upstreamProxyServer = new Server({ + port: upstreamProxyPort, + verbose: false, + }); + await upstreamProxyServer.listen(); + + // Main proxy with custom DNS that chains to upstream (uses chain.ts) + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + upstreamProxyUrl: `http://localhost:${upstreamProxyPort}`, + dnsLookup: createCustomDnsLookup(), + }), + verbose: false, + }); + await proxyServer.listen(); + + const response = await new Promise((resolve, reject) => { + const req = http.request({ + hostname: 'localhost', + port: proxyPort, + path: `http://localhost:${targetPort}/test`, + method: 'GET', + }, resolve); + req.on('error', reject); + req.end(); + }); + + expect(response.statusCode).to.equal(200); + expect(dnsLookupCallCount).to.be.equal(1); + }); + + it('resolves IPv6 addresses correctly', async () => { + targetServer = new TargetServer({ port: targetPort }); + await targetServer.listen(); + + let resolvedFamily = null; + + const ipv6DnsLookup = (hostname, options, callback) => { + dnsLookupCallCount++; + + if (hostname === 'ipv6-resolved.test') { + resolvedFamily = 6; + return callback(null, '::1', 6); + } + + // For localhost, use IPv4 + return dns.lookup(hostname, options, callback); + }; + + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + dnsLookup: ipv6DnsLookup, + }), + verbose: false, + }); + await proxyServer.listen(); + + // Make a request that triggers IPv6 resolution + try { + await new Promise((resolve, reject) => { + const req = http.request({ + hostname: 'localhost', + port: proxyPort, + path: 'http://ipv6-resolved.test/test', + method: 'GET', + timeout: 2000, + }, resolve); + req.on('error', reject); + req.end(); + }); + } catch (error) { + // Expected to fail, but DNS lookup should have been called + } + + expect(dnsLookupCallCount).to.be.equal(1); + expect(resolvedFamily).to.equal(6); + }); + + it('verifies custom DNS function is actually called', async () => { + targetServer = new TargetServer({ port: targetPort }); + await targetServer.listen(); + + let customDnsCalled = false; + const verifiableDnsLookup = (hostname, options, callback) => { + customDnsCalled = true; + dnsLookupCallCount++; + return dns.lookup(hostname, options, callback); + }; + + proxyServer = new Server({ + port: proxyPort, + prepareRequestFunction: () => ({ + dnsLookup: verifiableDnsLookup, + }), + verbose: false, + }); + await proxyServer.listen(); + + const response = await new Promise((resolve, reject) => { + const req = http.request({ + hostname: 'localhost', + port: proxyPort, + path: `http://localhost:${targetPort}/test`, + method: 'GET', + }, resolve); + req.on('error', reject); + req.end(); + }); + + expect(response.statusCode).to.equal(200); + expect(customDnsCalled).to.equal(true); + expect(dnsLookupCallCount).to.be.equal(1); + }); +}); diff --git a/test/fixtures/certificates/README.md b/test/fixtures/certificates/README.md new file mode 100644 index 00000000..92124027 --- /dev/null +++ b/test/fixtures/certificates/README.md @@ -0,0 +1,110 @@ +# Test Certificates + +⚠️ **TEST CERTIFICATES ONLY - DO NOT USE IN PRODUCTION** ⚠️ + +This directory contains self-signed test certificates for automated testing purposes only. These certificates are NOT trusted by any Certificate Authority and should NEVER be used in production environments. + +## Directory Structure + +``` +certificates/ +├── valid/ # Valid self-signed certificate for testing +│ ├── key.pem +│ └── cert.pem +├── expired/ # Backdated certificate (expired in 2021) +│ ├── key.pem +│ └── cert.pem +├── hostname-mismatch/ # Certificate for "example.com" (hostname mismatch) +│ ├── key.pem +│ └── cert.pem +└── invalid-chain/ # Certificate chain with missing intermediate + ├── root-ca.pem + ├── root-ca-key.pem + ├── leaf-cert.pem + ├── leaf-key.pem + └── leaf-csr.pem +``` + +## Certificate Details + +### Valid Certificate +- **Subject:** CN=localhost +- **Valid From:** Generated date +- **Valid To:** Generated date + 365 days +- **Purpose:** Testing successful TLS connections + +### Expired Certificate +- **Subject:** CN=localhost +- **Valid From:** 2020-01-01 +- **Valid To:** 2020-01-02 (expired) +- **Purpose:** Testing expired certificate handling + +### Hostname Mismatch Certificate +- **Subject:** CN=example.com +- **Usage:** Connect to 127.0.0.1 (triggers hostname mismatch) +- **Purpose:** Testing certificate hostname validation + +### Invalid Chain Certificate +- **Structure:** Root CA → Leaf (missing intermediate) +- **Purpose:** Testing incomplete certificate chain handling + +## Generation Commands + +### Valid Certificate +```bash +openssl req -x509 -newkey rsa:2048 -keyout valid/key.pem -out valid/cert.pem \ + -days 365 -nodes -subj "/CN=localhost" +``` + +### Expired Certificate +```bash +# Requires faketime (install: brew install libfaketime on macOS) +faketime '2020-01-01' openssl req -x509 -newkey rsa:2048 \ + -keyout expired/key.pem -out expired/cert.pem \ + -days 1 -nodes -subj "/CN=localhost" +``` + +### Hostname Mismatch Certificate +```bash +openssl req -x509 -newkey rsa:2048 -keyout hostname-mismatch/key.pem \ + -out hostname-mismatch/cert.pem -days 365 -nodes \ + -subj "/CN=example.com" +``` + +### Invalid Chain Certificate +```bash +# 1. Generate root CA +openssl req -x509 -newkey rsa:2048 -keyout invalid-chain/root-ca-key.pem \ + -out invalid-chain/root-ca.pem -days 365 -nodes \ + -subj "/CN=Test Root CA" + +# 2. Generate leaf certificate signing request +openssl req -newkey rsa:2048 -keyout invalid-chain/leaf-key.pem \ + -out invalid-chain/leaf-csr.pem -nodes \ + -subj "/CN=localhost" + +# 3. Sign leaf with root (skipping intermediate) +openssl x509 -req -in invalid-chain/leaf-csr.pem \ + -CA invalid-chain/root-ca.pem -CAkey invalid-chain/root-ca-key.pem \ + -CAcreateserial -out invalid-chain/leaf-cert.pem -days 365 +``` + +## Security Warnings + +⚠️ **IMPORTANT SECURITY NOTICES:** + +1. **Private Keys Exposed:** All private keys in this directory are committed to version control for testing purposes. These certificates must NEVER be used in production. + +2. **Self-Signed:** These certificates are self-signed and not trusted by any Certificate Authority or browser. + +3. **Test Only:** These certificates are solely for automated testing of TLS error handling, certificate validation, and edge cases. + +4. **No Real Security:** These certificates provide NO real security and should only be used in isolated test environments. + +## Regeneration + +If certificates need to be regenerated (e.g., valid certificate expired), run the generation commands above from the `test/fixtures/certificates/` directory. + +## Usage in Tests + +These certificates are loaded by `test/utils/certificate_generator.js` and used in `test/https_edge_cases.js` for testing various TLS scenarios. diff --git a/test/fixtures/certificates/expired/cert.pem b/test/fixtures/certificates/expired/cert.pem new file mode 100644 index 00000000..00bad927 --- /dev/null +++ b/test/fixtures/certificates/expired/cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtTCCAp2gAwIBAgIJAOyaEf+jBkG4MA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTcxMTEwMjAzNDM0WhcNMTgxMTEwMjAzNDM0WjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEAveAoPujCQ7RN2R09/Gp8cby6DyVOyob9VdiSJp8tTjWL0YmuGMdDa84n +BbUbig8z2J5zvnce8/kwIGEIpe9Aho4pNHe9+q+BaLNWdFdazDO2rVjIuDNvylqB +UZ3MeVY7uhVPIc7i4I8nh48dLIwCoo6bZuAKWjGNbOZ34iuvocixeLLjD9FPrfyS +miFNvYYBIIE1cuG6v4c/6D58TNkon2dIWk4WdT8exRggSSkcn0gkfj0V7c4pbJsh +xe2EihLEvT5CIL2oucQw0Nq1kzRBl9nIglrd7DO9CAYPlx3Kx3WoHG4MdibfbHbI +WcaWbQcNTKOXMQa5bEsijzEd3uzxrQIDAQABo4GnMIGkMB0GA1UdDgQWBBR/xkww +83cpqsT61bGnym/mFdbn9TB1BgNVHSMEbjBsgBR/xkww83cpqsT61bGnym/mFdbn +9aFJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUtU3RhdGUxITAfBgNV +BAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAOyaEf+jBkG4MAwGA1UdEwQF +MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAJvr1vJO373jCTztVWqs1DxVUpC8TMAO +zrv4Ry+1xcxowDkdyTNXhwfqshbrTEmfhl92zjNy4ZrYN/KN8kM+jg/fHbw5KNSd +uNH2a74BuXVQR/fscFPsqmIWlsyrSCKpRUi0dLKo67ZrBcnUMYwBnxdQxu0hoB81 +B5ZDLptogoc3YN8+XmjqghKEx22hC1+RalQ4pI3n7ru73NLukLJb2c4kjK9AsZq3 +44Q5+RajPtFha+mTlRyh9ZCMWgjzqESfvGKHoIq2gcLGWN2FuqKS9SIU8TfdUoh4 +N7ABI4y4lktKuq/5AcHZXuXwLiuCG3rOGeb6zgUV0jXb79C0unDWbTs= +-----END CERTIFICATE----- diff --git a/test/fixtures/certificates/expired/key.pem b/test/fixtures/certificates/expired/key.pem new file mode 100644 index 00000000..9665c202 --- /dev/null +++ b/test/fixtures/certificates/expired/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAveAoPujCQ7RN2R09/Gp8cby6DyVOyob9VdiSJp8tTjWL0Ymu +GMdDa84nBbUbig8z2J5zvnce8/kwIGEIpe9Aho4pNHe9+q+BaLNWdFdazDO2rVjI +uDNvylqBUZ3MeVY7uhVPIc7i4I8nh48dLIwCoo6bZuAKWjGNbOZ34iuvocixeLLj +D9FPrfySmiFNvYYBIIE1cuG6v4c/6D58TNkon2dIWk4WdT8exRggSSkcn0gkfj0V +7c4pbJshxe2EihLEvT5CIL2oucQw0Nq1kzRBl9nIglrd7DO9CAYPlx3Kx3WoHG4M +dibfbHbIWcaWbQcNTKOXMQa5bEsijzEd3uzxrQIDAQABAoIBAQCp2ZgG1m3Y5LRy +0I6/en5BvAJwQ/5cey6pmWb7t45ulMWzNkcPkUiFak9L8rtk376QOwXszmBY/IMJ +o+N5lDETbJ39elPuqQrJHwvqXK4zVttF69L5u8F3sUhXOyJLNFGPXzp/UrNvD3/b +6rC9Ra2hvpHTD/0Su5r4XJ3HKy8cN4ErQprmEJhKDYrP/Lp2uKDPwoxXiYcPOPKC +CbgPLRrK+40GbCXtZVQgX0+nrJ/0syryNaA9wb1wHXfdWLyhvM2BGHz9gXtv2z1L +3VvvbKpO2pygnLgUjLeJbk0/UI5Orz8MEAzGiq4wwiGqQZaGx/1C/WBlvnqnwuA5 +6vQQC7Q9AoGBAOoQZbir+W+ihkwIkTGb6pHnaPTAuslPSfR6RJbxGlv78wJ260KE +foRKVdb06gQWwZwHniKA0GZeKBFNxSSdkyt8yZY84/w9KLFitX5HaBFoggroOWx+ +JCMuPDDnnlHm9REUzR2Zsl3KedNeV92JUDjx+ObUbexeK8wd0mLtqcO7AoGBAM+r +mkJ4OrpTkPqVxkOim+LjMe/7EqX14QkPQrg3KzZmhyi7THOB5JMH+0/Bygrs1VP5 +OSEzWbPnjQ0GWoFEIw/iJbhvikhJPi4PXMrXIhreXcX7GezGp3nKHNf5vNpKAk4Z +fPlCmHEBtcBHPygDPEODkTPz7B74QX6NX4ZrWCW3AoGAMg7Psm8VKYrYreonIzT1 +Nb8H81BEokkSx/ZeNOnbeVCo6B4GsnMjm6dKNG6snbNANN5sM3TZHQuGBi1bvDj3 +AJXvhvH+0DNEQKubpSYgW5i+NxbzMQDJObzpoovmkB2Uy9JnC62TN/vVkh7bK8Xy +Ijudv8Auwh5hv4WhOQcbB4ECgYAqrG2PeRtATIm/JGXQYiq8TclmMeacGdF7RhqE +tjl3/UuK0CoeljN9DyfSNNUqt44CqnTV4LJvKIawhXy1kWXPDr6HjswQnJRdbKS5 +vclxUf5c/4NNR2kEusaAjv4CsTCWEeC/a7LdjedmMn3E4B1TFkcRMO91UbhLpAtc +GNTNMwKBgH954dHwNWbAXGJlqvP75MIuPdFNbi0TVKR8V9PbFg9eOVvWaeGGUr4I +yUoDPTndfogpiT/PuXBy4IQ+BYNza0fTVcJzTD5vOgoeRYUDdL5SYAlnIhEVhw2U +frBtb6JYt7jgP7HXyLG75+p+PVujxt20smxUKyLCfIqTNXeyIosW +-----END RSA PRIVATE KEY----- diff --git a/test/fixtures/certificates/hostname-mismatch/cert.pem b/test/fixtures/certificates/hostname-mismatch/cert.pem new file mode 100644 index 00000000..9233975a --- /dev/null +++ b/test/fixtures/certificates/hostname-mismatch/cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICqDCCAZACCQC4HL5mGUAtOjANBgkqhkiG9w0BAQsFADAWMRQwEgYDVQQDDAtl +eGFtcGxlLmNvbTAeFw0yNTEwMDcxMTU5MjRaFw0yNjEwMDcxMTU5MjRaMBYxFDAS +BgNVBAMMC2V4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAsmP2lRgDvbfxQ865spykBSttCnPJKLyoAKjBy5xkHifcRHr/HXi/5PcpAvb8 +s9IDU9d4yn4hHVvyjS6+lVLuoLHZKnBYCTX5vPNUljqv3HPSRhsY7DaVUGVy8NXX +Dy0jaCj06I547E5z5tU6PxDPPWZ9LHvvILgd/1IsmcD4Xz58MI6+7O1CMTSkvRYl +Q9r5mJc/nvaTp1O0PAGxsbz2tdgXxIJj2/8YhPvAB61goyaF0xIV8mwakIFF+eT5 +fnjDttGVnTpZaER/cp9u39xQFbYbkxnkFXNRpAZcs2lXeMDTNmC9RkJnO141e7j/ +nEFAPUbIVG5u5g3TDg4jok7jWQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAsjJ2j +lPYYZIP0QRLZaPQDLFwtap209W9TmPTyvmT4FDc0xihX/MMXN6ehoJ/K2aetBX1J +ZYvacPUF542IQCAH8zZ5/iOIUFKJngT7qUFiCSpvBg+DfGobsiFZmPg8MSLCbZbo +ct8mpJ6R9+088X83Q+bD0VdsA5dFarxBUPPpFZyCPxwwfJORV0+F7zVfagd38W4o +HPMx8cBsMzUaNHx2S6Z15ek262Ge78o9BJ0Zqzg+v9OoaoipPWj8JzIpK9YWHtMp +9yNUFWwGHbAeV92QstxbCtGAyQ2Jabn042Mrq1PyPyCCwcd6Eviko84rVrXzVH/m +vumqjXdcbbwZ1S8z +-----END CERTIFICATE----- diff --git a/test/fixtures/certificates/hostname-mismatch/key.pem b/test/fixtures/certificates/hostname-mismatch/key.pem new file mode 100644 index 00000000..696b535b --- /dev/null +++ b/test/fixtures/certificates/hostname-mismatch/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCyY/aVGAO9t/FD +zrmynKQFK20Kc8kovKgAqMHLnGQeJ9xEev8deL/k9ykC9vyz0gNT13jKfiEdW/KN +Lr6VUu6gsdkqcFgJNfm881SWOq/cc9JGGxjsNpVQZXLw1dcPLSNoKPTojnjsTnPm +1To/EM89Zn0se+8guB3/UiyZwPhfPnwwjr7s7UIxNKS9FiVD2vmYlz+e9pOnU7Q8 +AbGxvPa12BfEgmPb/xiE+8AHrWCjJoXTEhXybBqQgUX55Pl+eMO20ZWdOlloRH9y +n27f3FAVthuTGeQVc1GkBlyzaVd4wNM2YL1GQmc7XjV7uP+cQUA9RshUbm7mDdMO +DiOiTuNZAgMBAAECggEAJ+E3D8+ljnUfp5QAaZChDlLN9qc50jSSrksLv/P/Ycpx +cJG6lKEY7Rvf/Dyw85ZAji6+Xy5hQsn6aFuJ2aIHnL8FmHozBCQf54DSaR9Hj1YJ +LQkDwlLVgHqdfn+fq1Hg93kofxDSsak8Od9zPQQKAGT4GMjABaWDdz+sntbH76qN +1gjmzLwBPkdzP9FGbJUzxr8uTeleRRa1oo5rUDgm5VF5LfeRyM0EDOq9rDUh8SaN +tThwwGeN22vYYmmUObbRrJkQ/EOGbyRYDH27N4C+c/WsAGrP/DEWm0g/5X8Lhwf2 +foI4vtfMrLVPa+5lNRugxdELBZcIEo2IhyMP/WR28QKBgQDXB0BlMp2bHL6D4Ovp +lY9iKzHx/AdVr5QeNdv9dYtPhgFJmXEKAfUZPM33eleAqw8zVAjQbyQFpolRe9Hu +0KOHONPDjMWuJ/boIZKw3soDcWPOCjpxsM30edFMj0SDnuZQ86dr5nJgTxEIIqRZ +rTc5lp+uKWlh2/w5l6CTTzxpvQKBgQDUYZMKY2cem/Q9ojtsK+Y0pvQGB5YA0i5s +Ze85j+Hrud75Xy1h4gcd4PNlJdzM5qBv32X8IMedNJWaC/CkrssPYvKOwG/efQs5 +eIsGtMRDycOv7LxIodGBOR1L3nqsSlHt1b+akYOtc4tcrb3Wvr7/g59G7GCZVsh0 +h65jHQUDzQKBgF1kGOPbB/jGkzhUCCJ7grrwZ9Dh1Y2xpHM6kUGUO91eJlEBA8eE +8h7z+cDyse6AXSm95dYhb1PE8P8i+BrxIxUn6VGVYoYxdVt8uWl4WeUE6oQijR+z +2r/D6NOHpgpEiWO/b4e9nw+VR6Bw61DHmqS4dsH92ndWREX/RQ6161dBAoGAFLvJ +3RPDN2vGNlYmMvM+MBm0bPpEnKPoQFDbP+VaCudEa1ftWEb2puYVHOTLX32MYB+R +F0Ij+qbti/JqdclSrZfdUi5bPX87n0qzV95B4tRJtF0KLJUPnOv0fjmkBDnlMbDS +Wcam8kH7cvrLM7G/d7Zj0Eq3S4D7ZNTyI17r5GkCgYA+unLjnOVpgoi9B/wW9rZm +JAzyD0z9xfr/dgOLGEDRrYXqeOPegwR1si4GHAv6gO3QNqFQgsGw4O+Qo9sMpibP +WWa1HlBBbSbN51PoxuxhIOAA1dkGu3g+bw7Kq2ItY5Z+QOBYfh8V7Ji7kN5+5h3i +bV6jFFWVo9GdR+SZ9rIxOA== +-----END PRIVATE KEY----- diff --git a/test/fixtures/certificates/invalid-chain/leaf-cert.pem b/test/fixtures/certificates/invalid-chain/leaf-cert.pem new file mode 100644 index 00000000..60493058 --- /dev/null +++ b/test/fixtures/certificates/invalid-chain/leaf-cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICpzCCAY8CCQDv3IIb+Ev2sDANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxU +ZXN0IFJvb3QgQ0EwHhcNMjUxMDA3MTIwMjM4WhcNMjYxMDA3MTIwMjM4WjAUMRIw +EAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDTsSVJtIKXyKRerIzQPlqihLd7usE2Q0+059nMr+4rRRFfAynoimExzklxtjDy +ji0SbrltuTwX6v0cln5l8GyPwlV2v1EBDxNZvwgtSnpSZsNoxKW23VP6oLgK8980 +sI+5v6lB+kM+m7NBmK9FH4Ttssm77CRD5/Cdp9s5qn+632IhMUyiVgnKeG294mnZ +HnEAmyPyxigjtMpBe9dJEk6/h5FKhDkxqgIRBr5zog3UWrNf2fVIqqRJOcwfzThP +6QsiLp0qivZsezs9ArXJjswVw7PWreXYvoCPhvORO210DL8qtO6jb6wYBWWIwW/a +FxeGwLvAxk12BdiB0qUb3pSDAgMBAAEwDQYJKoZIhvcNAQELBQADggEBABCBkQKv +sCxa67U4Rfcfrkunhd6Aarb+WhEjYxUGnx3ff+0LL6Ds+uxdlU2GBrh8BRL3uyqI +4fv00hUJKg4euy6qR34bkfZZ2MXgJyVqy076t0eauO1YmjRuE+jGTpIpT/aHsOh+ +mT2oSaVIpXCqNdmQWwXj90QNjjP/hSXC+06MLpp9XfgL9jlBO67vse7uilocd7hA +Oii1a8SegLeVqK80oY5XNH0R1Q1vm6VJqKf3HlLIGWHZAE7mCCmApRqmb71f0VaP +YM8PZruEdIwC31gaCIzDRsKhkRs14snPKkefECMX8Qbj2TSidAxPaOlTn6cpuQNu +nRrSyGEZL/R++aQ= +-----END CERTIFICATE----- diff --git a/test/fixtures/certificates/invalid-chain/leaf-csr.pem b/test/fixtures/certificates/invalid-chain/leaf-csr.pem new file mode 100644 index 00000000..fbb4c5bc --- /dev/null +++ b/test/fixtures/certificates/invalid-chain/leaf-csr.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICWTCCAUECAQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA07ElSbSCl8ikXqyM0D5aooS3e7rBNkNPtOfZzK/u +K0URXwMp6IphMc5JcbYw8o4tEm65bbk8F+r9HJZ+ZfBsj8JVdr9RAQ8TWb8ILUp6 +UmbDaMSltt1T+qC4CvPfNLCPub+pQfpDPpuzQZivRR+E7bLJu+wkQ+fwnafbOap/ +ut9iITFMolYJynhtveJp2R5xAJsj8sYoI7TKQXvXSRJOv4eRSoQ5MaoCEQa+c6IN +1FqzX9n1SKqkSTnMH804T+kLIi6dKor2bHs7PQK1yY7MFcOz1q3l2L6Aj4bzkTtt +dAy/KrTuo2+sGAVliMFv2hcXhsC7wMZNdgXYgdKlG96UgwIDAQABoAAwDQYJKoZI +hvcNAQELBQADggEBAH/UTaiJWpv5mM+RDxp8vyuJzaui/veyEtts6rwQeuh95GQl +9c1kqjNiEHeSWe2ct4N+i0TMfZEV6pi1fz+5nlyEyPwvcDehkDUK4JNtGecylu0E +kbA6JkuzISpVOwOvN1uwmUH27BSWwKMB0aighPMlr58E3/0si3wscdskCEPFYipM +Nx57Mb7Giahu5+y6GOiyV/1+QdvpWnLBPWnJb8LSszvAOw1du4we4MRP8gu7EWTn +zcDIxiu8WJY1LoWv/Kvh5uTrt88XCKgTzF0JMe356eSnazcbEsGR97XBSkVyuz2J +uCtgp2mnYD7Q5+jFP1IlfFjB74qmak7S2K5cN68= +-----END CERTIFICATE REQUEST----- diff --git a/test/fixtures/certificates/invalid-chain/leaf-key.pem b/test/fixtures/certificates/invalid-chain/leaf-key.pem new file mode 100644 index 00000000..4f1a2b6d --- /dev/null +++ b/test/fixtures/certificates/invalid-chain/leaf-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDTsSVJtIKXyKRe +rIzQPlqihLd7usE2Q0+059nMr+4rRRFfAynoimExzklxtjDyji0SbrltuTwX6v0c +ln5l8GyPwlV2v1EBDxNZvwgtSnpSZsNoxKW23VP6oLgK8980sI+5v6lB+kM+m7NB +mK9FH4Ttssm77CRD5/Cdp9s5qn+632IhMUyiVgnKeG294mnZHnEAmyPyxigjtMpB +e9dJEk6/h5FKhDkxqgIRBr5zog3UWrNf2fVIqqRJOcwfzThP6QsiLp0qivZsezs9 +ArXJjswVw7PWreXYvoCPhvORO210DL8qtO6jb6wYBWWIwW/aFxeGwLvAxk12BdiB +0qUb3pSDAgMBAAECggEADKfUgNmHzScznG1YZcK0jG6+wWji0CmqBDiwLqp95JxW +c4Wu5bYQJXgdr+yidH3HeAiikUq3qv5jb2gX2mRLOTT3AwhhAV0zXPQsuvhu46o9 +GHBZL9t/f8ZH+m+l8nJzxTkOF2Gsz3tjdhJ4t/swaT19Df0KFf0xx1sXohTtWfCQ +/oVtgQ4Uds0QqIIoQ30GuLUbs3eg/R+LSS/pgiLmeEgYW9CjuASLQry/FaYrTX14 +mOKTEbWDmDkllUTaZEqdVW9t1HixnPIc6A7kD3/MXg8fgCnZf/ttdW+76BpDf2qd +Kzl0tWX86FMX+sXhQx8qRPge6TeJ606vQxY5GlMQUQKBgQD2IzpRY6JxIwZ8SPFm +cDPU9Z+3F1Fm7Am9FBfmfXlH28WPdmlzBWPCoNr2PZ7TWAZbYmYGhYp2rQ2Bvqgj +mQwdguybndCTr/yNxqjQhqYuGn9mbTQn7yRRQxTvh/Qs/tOyHN3EOyxlH3CYda6F +1fsI6QTAZRPA9/KHSomYudcsBQKBgQDcLJbuRrM8VlMB80EzIXtB5E/yooYa8dnO +y7HpFQM6sSXvRyrgydsjY6AZE0z/9rmdOgLl/Pp1G8xBEvhpMFQhwn3GOl26k3rE +UeespuMjJxPZam7JbFntYUasXIJ5t1bOuxR1XHTI2G8lSp8hW8RpTG0wt+1pJ2VY +rfm2wKEs5wKBgHt2FiWHDY8kW3dx+yw/8a/LeM6U8q7mjMf70TU7EN+bfFBGvAQG +2xBgMRS8ufMWvzGNfNGeoGKA0TqYUKxyc2JGLrqsAVhjORJAaKMYNzj+75KIClZR +nOzp8hFoJ3F+bN91nUN1zwH2MDs5JE5bk+zXPRvoV20+sbdtCvHBng2lAoGBAIa3 +sdIx6FGu8DNtSCOlBOoYbOKllzC9cuwZrs5TURqEchWqkg3g9Pj+aGNvb9neeUw+ +xq86z3UAgUX/+YdDTvcpLhQwelFDFAczpnH521kS/A44FXmhlpSsxLWKFTusdq0f +wzDcVYjfBYLn2d5rbA6R+O2qXNXOiTBtozGzQ+tZAoGBAPG7n45VOOrpJhIulIYc +g0HPY8zy6Ug9Ffk9NxRLuH8NJnRNEz8edJaFLT/+eL/dxgBkKI+C9BDD8uCMDvP3 +bTn4oK9WDmj6cUUT556RO1+ApXdI2TrZRBvNyGV3wnewRLg6/uZSlFiRJbSKG36V +Y6XYKcVzIT9rELtgKlS6QjUf +-----END PRIVATE KEY----- diff --git a/test/fixtures/certificates/invalid-chain/root-ca-key.pem b/test/fixtures/certificates/invalid-chain/root-ca-key.pem new file mode 100644 index 00000000..d871779a --- /dev/null +++ b/test/fixtures/certificates/invalid-chain/root-ca-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDIgQoYHTVZzui6 +6KowmOFH+DmftkHNd4JA/VH+zB/3j2tBvWijyuyb3IIeQFxDtuKrdtdfGj9P/x/v +XTVMNZGiipEcc+3pFTa2hrNDUIf8NK6jKIDE8GsoStDbBeGmBvfuteDzy6bvT9Fz +jXB/oF0Mos3yo3xvKpLhZxCFy9lqMQiMVhS+efiq2ttdno774b/eCch8HZFHio0l +OhOJmaTCzB6AE/PxT+LllCVXtj+b11Wj7DwVY1SZkQ2pEuCnZUyPbdL0Nk/NVmys +1JsA+qQSPgqtwtzyKMnUbuVlXSz/FE8pIarYTV9eJkKiKKn653QQ+X9iq2WyD3/E +hK22Fk9lAgMBAAECggEAbu2FSiYOuCsS8yV50v0h1hFswAGzkhtGQvJjhNYl8W61 +Zl+GHuhL00dB7b6pzQTHjxlmukTpNjbt0h39OLaCZizHb7hrrYKBDKalUt6g/VWg +MMBdzZc9kcMinao5VwOP889IL1lCeC/ur0FXNiTglfcUfeMZGt+w92dv6rYHCQov +TIWFAhRTe5syD/aozE5DSR9RUYfxEc/tdkNay8oVkc2TCpVJ7fRns11JTjGG9+gg +hL0BdQ3/35zNKQAIQpounQuEr5QORPKeCiWNSHzKGHT1c/LFlwO73u7p6cnKcI0S +HHGYPezVabJQhEelrOSxkMIM6RcN96F5v6pODE/bGQKBgQD+mx0fIIc+uJpeOlbd +z96InBchwR4rIHfLeHm0REmmRoMLpAHjQUfYcOorSwLXVMQBEmxvlVTGav5nBSl/ +Y5U/uQEjAYOH0t9T29mMc097Z1QmmVsPqTWK1wW4eZ85Jln5C2pqggGj2tQPYiZX +HXiuim11fo63vTgN5LUvd7RSPwKBgQDJmhcLSA3/RFGJBP/d/9Wi1f0G8fKhH65R +n4dF/G17ucKitZpCQfvilF9w92aCmmyWGRB7P1gTtooWkJ0o7oElvQi2XYhkFEWu +Nhqthw83X3jBAsRIojMmy73eDM0oxQ+NVSvQxTHacsmMsDpJHh5VwvWftQEqD6zq +q+pW13gtWwKBgFbU8e6eumFSthryzJNjhGKU0XLKmQ5eSVzUXrVzIKcbny/GJHqr +1ePkRoizUWm82AgBj4fSpxHwhYj/ArMWdF1CLDgTGRemfFHuRdiXrwDIMbPkU2EY +9VC/mozatcnY4ZCFKyrd4c9PH0mL0MWfIHxua3xJyESzvL1IRd7FdXMjAoGBAIR/ +ASYo9QoPnIaxAnik64NZoDIwUYYTjD2Y2w7kGBB4xbKqJ/fj5efEG/XiozyywSrk +zjAyMXDDR34NDT0Zg0eKNW+liT49FI6qgF4LpbR+yp4Pc3FJKNUWknKddziUSuRY +VbOf5mPrjQspxyTG5qj2uPd9voYmRz70Pc9VTWaBAoGBANJsV0v/4QL8d4DpkIHP +gos8N1F48ed9guMbJKQCCJekYncObGBxDKpQ9nXXLUOkxlwCV7HfxJ+ud4drboro +0B1mzENpXivqSr9NZJQMtAaFPAe1KjXk3u/gR62aDTq4dsR406lSFs/lVlm5C8Ch +a9P0K0JmT5pkgNMHi5mnDpXP +-----END PRIVATE KEY----- diff --git a/test/fixtures/certificates/invalid-chain/root-ca.pem b/test/fixtures/certificates/invalid-chain/root-ca.pem new file mode 100644 index 00000000..bc6df375 --- /dev/null +++ b/test/fixtures/certificates/invalid-chain/root-ca.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICqjCCAZICCQDCVft0HObkNTANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxU +ZXN0IFJvb3QgQ0EwHhcNMjUxMDA3MTIwMjIwWhcNMjYxMDA3MTIwMjIwWjAXMRUw +EwYDVQQDDAxUZXN0IFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQDIgQoYHTVZzui66KowmOFH+DmftkHNd4JA/VH+zB/3j2tBvWijyuyb3IIe +QFxDtuKrdtdfGj9P/x/vXTVMNZGiipEcc+3pFTa2hrNDUIf8NK6jKIDE8GsoStDb +BeGmBvfuteDzy6bvT9FzjXB/oF0Mos3yo3xvKpLhZxCFy9lqMQiMVhS+efiq2ttd +no774b/eCch8HZFHio0lOhOJmaTCzB6AE/PxT+LllCVXtj+b11Wj7DwVY1SZkQ2p +EuCnZUyPbdL0Nk/NVmys1JsA+qQSPgqtwtzyKMnUbuVlXSz/FE8pIarYTV9eJkKi +KKn653QQ+X9iq2WyD3/EhK22Fk9lAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHFD +XWNn0LiLU1+G7NaokFTbjlHJ0AUgMZ2M+HUrlRx9nJbysgHENbW9uItSGJ84lOf5 +B90SAeD5FV/V+ghfCMY2IUUY1T2d2/C1BhponYqpNZ8w8SQjS0RfgewQ4cKehfhS +MlNrl3/dk297Vfd4rTekLNVoueQn/f0ZiX2WaXui5k+LmAnJlWL8NKYS8w9N1w5G +uq+NkMoU+ggivwY/1scDmpCZ2hNrDhCt87lOutE+hb4K3DxkjS8QEX2nnVRlH1gg +3+cEt2TYNJh/YiIFiN7JFYF86mhxOv4WZGcYHkbTZPSNKgVXQu+bUM8ADuEuGZdH +im2ewqovsN1hcsJEFUM= +-----END CERTIFICATE----- diff --git a/test/fixtures/certificates/invalid-chain/root-ca.srl b/test/fixtures/certificates/invalid-chain/root-ca.srl new file mode 100644 index 00000000..86e92238 --- /dev/null +++ b/test/fixtures/certificates/invalid-chain/root-ca.srl @@ -0,0 +1 @@ +EFDC821BF84BF6B0 diff --git a/test/fixtures/certificates/valid/cert.pem b/test/fixtures/certificates/valid/cert.pem new file mode 100644 index 00000000..6c7fa5fd --- /dev/null +++ b/test/fixtures/certificates/valid/cert.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICpDCCAYwCCQDkuACl+oKxZzANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAls +b2NhbGhvc3QwHhcNMjUxMDA3MTE1OTE0WhcNMjYxMDA3MTE1OTE0WjAUMRIwEAYD +VQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDU +q89A0zBZxAL4uv863640YaTLCAuwC+in4gwROrVyQNltcvU2cVDkbfyhXJUydAXj +c5f3sn0tgL7Cemy7fLID3K6g/DTwc/z/O+mQcp938xXWKErIpeZBhhpvgh2zF2yo +JYiLUXB7gxOJBeGmRNQUdB8JB6xgstLc3094juz1so1PlkuIkLUEOkuqDZjbf/63 +erWsUl1zWvncw1DvbS4fvbWEBAgkdxW2KYOfkJ/6FfRAjezJ2+OduYODtDO89oeU +b4oWV24aZZOBlQEusBIbIexNMKGp4ArIKtLuMqZpY7MttEPqkUBFpWBfFhI7h+8X +YN/KJzgDpzHhhTcpuE4bAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEqYVEE9JOrW +ZlmsAvJTjdFnkSypO9Z6kIVPfuRecJKvSo/Wk7qboaMhHY1ajnbQT83vvWS44aER +RD6nacg+Jm9uukv3LMEnW+Euo40wyqYWaT8rz1NJCAg/5WaWTc53kZ09trb6FRig +3YIVoriyEREO97T2Q9s+E8OScdhWNLNlpKT5/GUO/hlEQ/gTiC8rLERy/1Cb5rjQ +UQ/kzvMoXHlH0nAa+0n0qGyYqYwkWINbH9iKeKBUWG/WUJBSggvjqKWVYgKdGoPm +VAdRtYUFHYiL66SItl/tvsGV4UOeF61X2Us4gv4EmNHWGgRcAat1q/QK9kQ7/bmm +9rLb+H5YTCc= +-----END CERTIFICATE----- diff --git a/test/fixtures/certificates/valid/key.pem b/test/fixtures/certificates/valid/key.pem new file mode 100644 index 00000000..b9c04a0b --- /dev/null +++ b/test/fixtures/certificates/valid/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDUq89A0zBZxAL4 +uv863640YaTLCAuwC+in4gwROrVyQNltcvU2cVDkbfyhXJUydAXjc5f3sn0tgL7C +emy7fLID3K6g/DTwc/z/O+mQcp938xXWKErIpeZBhhpvgh2zF2yoJYiLUXB7gxOJ +BeGmRNQUdB8JB6xgstLc3094juz1so1PlkuIkLUEOkuqDZjbf/63erWsUl1zWvnc +w1DvbS4fvbWEBAgkdxW2KYOfkJ/6FfRAjezJ2+OduYODtDO89oeUb4oWV24aZZOB +lQEusBIbIexNMKGp4ArIKtLuMqZpY7MttEPqkUBFpWBfFhI7h+8XYN/KJzgDpzHh +hTcpuE4bAgMBAAECggEAUzTZRKqcCzLmWryG6VjkhngBirIeKIWwRCbrw73ticMX +jnvgtqlFFep2YgegE/pS53etaGr8ENaDTAGsEvqph1tLM48Zg05OGOou6qX+AGcq +Dvz6rWBDIzW9mA0XY59xkqQEtUVqtdhFx2F2tJ/PPiVkkxredYLhfysbo7Z/vWyk +KiGOaJb+o3B21GMjGVcCvbbPRDaDlQ36Sw6wId6eczbmEwBbSLPWlNgbMrGEls7l +zmUWHkG5JhezXHNDYhiaWaVLcnKbpmQi7P2+vRIE9kAbw3ktLomjQql77Cwn3oxA +eq6kWvGvZJ6wI+Is23DGHs+p79Sv3ZEZK/+opCkKwQKBgQD9cP5bV3WcxoDGleFh +1/SvryALbCKwqFDgfqblQLczMlpR5sbMgVuEsV1XFbGUzckXhrmLFS54E2Dq41ry +c2LCpE3shgbAqZ1cczW0SNSl3MzDz1tpV/E4dEmssHY+JMm4+s+H3TZZb3IgRkTM +HQZtOTe4+qqNHMp/2WQB9gMV4QKBgQDW0XKF1Kjs3cHiPbdtVQM50cGPeS/oGjoe +Y6Rw3ltm7Q4nGiUUK3VFHQ1E8QTXHPhNLtA2lNcKFA6ZpKpd5eMT60XL7pqErQSL +lugp9KflII9D1PlTXA743Ometi6p9KsUZcLEwynbXK8auyeCJZkk0iwPOSvmZNxU +IZSlg5ArewKBgQCqc81OR4Mdfs/rP7CGqsSxR9oIdKDcKUfDxMqgkybjcvEV9l0r ++7A+jT9Wq4t0pfaiMQUnCobnUTk0oNZxC7OXbwPNmNJ/z1O7cuzipL0IFHlPFG3J +atEcg/FtCH1uDDXziBP9r4S3H+Ik3L22fart2LamXUhJgdyboezF+NxYYQKBgFWP +kLS7Uhkd7lZlTITQgntqD1VM6Ibiw1lNSLbn6bHiI2vxnukcshW4D4vxD4N6d2+O +gMHsoIXUHUiW7IB/yuUpJWCnhYXov9G1Zn0nhCPfxxA2aKQKG7CFlMtxNr7cw7NZ +IK1sKmTD0r6r4n2U6h1fVMsvc0vNym/7/A+8ihS3AoGAdTid6qi0JzgyGJD9lBP9 +LlUS8GbZFAmuMl+VHyFV+PKON2gfHOeNBzDYtb/FEDOfHEeLD2V1afqHRUKnEZzl +6EMa7DeMmjASafKIIXd+qvZsIo7c9EZVJkbxhG/zPx57DltQ+5b8jlT0ifL+hdjY +JVmQskLfcCl2PsjvMSjF3MU= +-----END PRIVATE KEY----- diff --git a/test/https_edge_cases.js b/test/https_edge_cases.js new file mode 100644 index 00000000..e73fea80 --- /dev/null +++ b/test/https_edge_cases.js @@ -0,0 +1,903 @@ +const { expect } = require('chai'); +const portastic = require('portastic'); +const request = require('request'); +const { Server } = require('../src/index'); +const { loadCertificate, verifyCertificate, certificateMatchesHostname } = require('./utils/certificate_generator'); +const tls = require('tls'); +const { TargetServer } = require('./utils/target_server'); + +/** + * Check if Node.js version supports crypto.X509Certificate (added in v15.6.0) + */ +const supportsX509Certificate = (() => { + const [major, minor] = process.versions.node.split('.').map(Number); + return major > 15 || (major === 15 && minor >= 6); +})(); + +const requestPromised = (opts) => { + return new Promise((resolve, reject) => { + request(opts, (error, response, body) => { + if (error) { + return reject(error); + } + resolve(response, body); + }); + }); +}; + +describe('HTTPS Edge Cases - Certificate Validation', function () { + this.timeout(30000); + + let freePorts; + let targetServer; + let proxyServer; + let upstreamProxy; + + before(async () => { + freePorts = await portastic.find({ min: 51000, max: 52000 }); + }); + + afterEach(async () => { + if (upstreamProxy) { + await upstreamProxy.close(true); + upstreamProxy = null; + } + if (proxyServer) { + await proxyServer.close(true); + proxyServer = null; + } + if (targetServer) { + await targetServer.close(); + targetServer = null; + } + }); + + describe('Expired Certificates', () => { + it('rejects HTTPS proxy with expired certificate (strict SSL)', async () => { + const expiredCert = loadCertificate('expired'); + const proxyPort = freePorts.shift(); + + // Verify certificate is actually expired (only on Node 15.6.0+) + if (supportsX509Certificate) { + const certInfo = verifyCertificate(expiredCert.cert); + expect(certInfo.isExpired).to.be.true; + } + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: expiredCert.key, + cert: expiredCert.cert, + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + try { + await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: true, + rejectUnauthorized: true, + agent: false, // Disable connection pooling for test isolation + }); + expect.fail('Should have rejected expired certificate'); + } catch (error) { + // Should fail with certificate expired error + // Node.js returns CERT_HAS_EXPIRED for expired certificates + expect(error.message).to.match(/CERT_HAS_EXPIRED|certificate.*expired/i); + } + }); + + it('accepts HTTPS proxy with expired certificate (ignore SSL errors)', async () => { + const expiredCert = loadCertificate('expired'); + const proxyPort = freePorts.shift(); + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: expiredCert.key, + cert: expiredCert.cert, + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + const response = await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + expect(response.statusCode).to.equal(200); + expect(response.body).to.equal('Hello world!'); + }); + + it('handles upstream HTTPS proxy with expired certificate', async () => { + const expiredCert = loadCertificate('expired'); + + const upstreamPort = freePorts.shift(); + upstreamProxy = new Server({ + port: upstreamPort, + serverType: 'https', + httpsOptions: { + key: expiredCert.key, + cert: expiredCert.cert, + }, + }); + await upstreamProxy.listen(); + + // Create main HTTP proxy that chains to upstream HTTPS proxy + const mainProxyPort = freePorts.shift(); + proxyServer = new Server({ + port: mainProxyPort, + serverType: 'http', + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `https://127.0.0.1:${upstreamPort}`, + }; + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + // Request through main proxy (which uses upstream HTTPS proxy with expired cert) + // Should fail with 599 error + const response = await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `http://127.0.0.1:${mainProxyPort}`, + }); + + // TLS errors (CERT_HAS_EXPIRED, etc.) fall back to 599 - see errorCodeToStatusCode in statuses.ts + expect(response.statusCode).to.equal(599); + }); + }); + + describe('Hostname Mismatch', () => { + it('rejects certificate with wrong hostname (strict SSL)', async () => { + const mismatchCert = loadCertificate('hostname-mismatch'); + const proxyPort = freePorts.shift(); + + // Verify certificate is for example.com, not localhost (only on Node 15.6.0+) + if (supportsX509Certificate) { + expect(certificateMatchesHostname(mismatchCert.cert, 'example.com')).to.be.true; + expect(certificateMatchesHostname(mismatchCert.cert, '127.0.0.1')).to.be.false; + expect(certificateMatchesHostname(mismatchCert.cert, 'localhost')).to.be.false; + } + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: mismatchCert.key, + cert: mismatchCert.cert, + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + // Attempt to connect to 127.0.0.1 with certificate for example.com + try { + await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: true, + rejectUnauthorized: true, + agent: false, // Disable connection pooling for test isolation + }); + expect.fail('Should have rejected certificate with hostname mismatch'); + } catch (error) { + // Should fail with hostname validation error or self-signed certificate error + // Node.js may return ERR_TLS_CERT_ALTNAME_INVALID for hostname mismatches, + // or may reject self-signed certificates before checking hostname + expect(error.message).to.match(/ERR_TLS_CERT_ALTNAME_INVALID|CERT.*ALTNAME|Hostname.*mismatch|does not match|self.*signed.*certificate/i); + } + }); + + it('accepts certificate with correct hostname (ignore SSL)', async () => { + const mismatchCert = loadCertificate('hostname-mismatch'); + const proxyPort = freePorts.shift(); + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: mismatchCert.key, + cert: mismatchCert.cert, + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + const response = await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + expect(response.statusCode).to.equal(200); + expect(response.body).to.equal('Hello world!'); + }); + }); + + describe('Invalid Certificate Chain', () => { + it('rejects HTTPS proxy with incomplete certificate chain (strict SSL)', async () => { + const invalidChainCert = loadCertificate('invalid-chain'); + const proxyPort = freePorts.shift(); + + // Create HTTPS proxy with incomplete certificate chain + // The certificate is signed by a root CA, but the chain is incomplete + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: invalidChainCert.key, + cert: invalidChainCert.cert, + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + try { + await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: true, + rejectUnauthorized: true, + agent: false, // Disable connection pooling for test isolation + }); + expect.fail('Should have rejected certificate with incomplete chain'); + } catch (error) { + // Should fail with certificate chain verification error + // Node.js may return various messages for invalid certificate chains: + // - UNABLE_TO_VERIFY_LEAF_SIGNATURE + // - SELF_SIGNED_CERT_IN_CHAIN + // - "unable to verify the first certificate" + expect(error.message).to.match(/UNABLE_TO_VERIFY_LEAF_SIGNATURE|SELF_SIGNED_CERT_IN_CHAIN|unable to verify|self.*signed/i); + } + }); + + it('accepts HTTPS proxy with incomplete certificate chain (ignore SSL errors)', async () => { + const invalidChainCert = loadCertificate('invalid-chain'); + const proxyPort = freePorts.shift(); + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: invalidChainCert.key, + cert: invalidChainCert.cert, + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + const response = await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + expect(response.statusCode).to.equal(200); + expect(response.body).to.equal('Hello world!'); + }); + + it('handles upstream HTTPS proxy with invalid certificate chain', async () => { + const invalidChainCert = loadCertificate('invalid-chain'); + + const upstreamPort = freePorts.shift(); + upstreamProxy = new Server({ + port: upstreamPort, + serverType: 'https', + httpsOptions: { + key: invalidChainCert.key, + cert: invalidChainCert.cert, + }, + }); + await upstreamProxy.listen(); + + // Create main HTTP proxy that chains to upstream HTTPS proxy + const mainProxyPort = freePorts.shift(); + proxyServer = new Server({ + port: mainProxyPort, + serverType: 'http', + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `https://127.0.0.1:${upstreamPort}`, + }; + }, + }); + await proxyServer.listen(); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + // Request through main proxy (which uses upstream HTTPS proxy with invalid chain) + // Should fail with 599 error + const response = await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `http://127.0.0.1:${mainProxyPort}`, + }); + + // TLS errors (UNABLE_TO_VERIFY_LEAF_SIGNATURE, etc.) fall back to 599 - see errorCodeToStatusCode in statuses.ts + expect(response.statusCode).to.equal(599); + }); + }); + + /** + * These tests validate certificate checking at each hop in complex proxy chains. + * Each connection (client -> proxy, proxy -> upstream, upstream -> target) is validated + * independently with different certificate states. + */ + describe('Multi-Stage Certificate Validation', () => { + it('validates certificates independently at each proxy hop', async () => { + const validCert = loadCertificate('valid'); + const expiredCert = loadCertificate('expired'); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: true, + sslKey: validCert.key, + sslCrt: validCert.cert, + }); + await targetServer.listen(); + + // Create upstream HTTP proxy (no cert issues) + const upstreamPort = freePorts.shift(); + upstreamProxy = new Server({ + port: upstreamPort, + serverType: 'http', + }); + await upstreamProxy.listen(); + + const mainProxyPort = freePorts.shift(); + proxyServer = new Server({ + port: mainProxyPort, + serverType: 'https', + httpsOptions: { + key: expiredCert.key, + cert: expiredCert.cert, + }, + prepareRequestFunction: () => { + return { + upstreamProxyUrl: `http://127.0.0.1:${upstreamPort}`, + }; + }, + }); + await proxyServer.listen(); + + // Connect to main HTTPS proxy with expired cert + // Client-to-proxy connection should fail (expired cert) + // Even though target has valid cert + try { + await requestPromised({ + url: `https://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${mainProxyPort}`, + strictSSL: true, + rejectUnauthorized: true, + agent: false, // Disable connection pooling for test isolation + }); + expect.fail('Should have rejected expired certificate at proxy level'); + } catch (error) { + // The expired certificate is also self-signed. Node.js may check either: + // 1. Certificate expiration first (CERT_HAS_EXPIRED) + // 2. Certificate chain first (self-signed certificate) + // Both indicate certificate validation failure + expect(error.message).to.match(/CERT_HAS_EXPIRED|certificate.*expired|self.*signed|TLS|SSL/i); + } + }); + + it('handles HTTPS proxy with HTTP target (protocol isolation)', async () => { + const validCert = loadCertificate('valid'); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + const proxyPort = freePorts.shift(); + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + // Validates protocol isolation: client-proxy (HTTPS) and proxy-target (HTTP) + // connections are independent. + // + // NOTE: HTTPS proxy -> HTTPS target cannot be tested with the `request` library + // due to tunnel-agent bug (request/request#2762) where rejectUnauthorized is not + // passed to the proxy connection. + // TODO: Migrate to impit to enable HTTPS -> HTTPS testing + const response = await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + // Request succeeds - proves protocol isolation works + expect(response.statusCode).to.equal(200); + expect(response.body).to.equal('Hello world!'); + }); + }); + + describe('HTTPS Target Certificate Handling via CONNECT', () => { + // NOTE: + // When a client makes a CONNECT request to establish a tunnel to an HTTPS target, + // the proxy creates a raw TCP tunnel between the client and target. The TLS + // handshake happens directly between the client and target - the proxy never + // sees or validates the target's certificate. + // + // This is the CORRECT behavior per RFC 7231 (CONNECT method specification). + // The proxy is protocol-agnostic and simply pipes bytes bidirectionally. + // + // These tests document and verify this expected behavior. + + it('allows CONNECT tunnel to HTTPS target regardless of target certificate', async () => { + const expiredCert = loadCertificate('expired'); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: true, + sslKey: expiredCert.key, + sslCrt: expiredCert.cert, + }); + await targetServer.listen(); + + const proxyPort = freePorts.shift(); + proxyServer = new Server({ + port: proxyPort, + serverType: 'http', + }); + await proxyServer.listen(); + + // The proxy will successfully create the tunnel regardless of target certificate + // This documents that the proxy doesn't validate target certificates + // (The client would see the certificate error if it validated) + const response = await requestPromised({ + url: `https://127.0.0.1:${targetPort}/hello-world`, + proxy: `http://127.0.0.1:${proxyPort}`, + strictSSL: false, // Client ignores certificate errors + rejectUnauthorized: false, + }); + + // Request succeeds through tunnel, demonstrating: + // 1. Proxy created TCP tunnel successfully (doesn't validate target cert) + // 2. Client performed TLS handshake through tunnel + // 3. Client chose to ignore certificate errors (strictSSL: false) + expect(response.statusCode).to.equal(200); + expect(response.body).to.equal('Hello world!'); + }); + + it('client validates HTTPS target certificate through HTTP proxy tunnel', async () => { + const expiredCert = loadCertificate('expired'); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: true, + sslKey: expiredCert.key, + sslCrt: expiredCert.cert, + }); + await targetServer.listen(); + + const proxyPort = freePorts.shift(); + proxyServer = new Server({ + port: proxyPort, + serverType: 'http', + }); + await proxyServer.listen(); + + // Client attempts to validate target certificate through tunnel + // The proxy creates the tunnel successfully, but the CLIENT rejects + // the target's expired certificate during the TLS handshake + try { + await requestPromised({ + url: `https://127.0.0.1:${targetPort}/hello-world`, + proxy: `http://127.0.0.1:${proxyPort}`, + strictSSL: true, // Client validates certificate + rejectUnauthorized: true, + agent: false, // Disable connection pooling for test isolation + }); + expect.fail('Client should have rejected expired target certificate'); + } catch (error) { + // Client (not proxy) detects and rejects the expired certificate + // This proves TLS handshake happens between client and target + expect(error.message).to.match(/CERT_HAS_EXPIRED|certificate.*expired/i); + } + }); + }); +}); + +describe('HTTPS Edge Cases - TLS Version Negotiation', function () { + this.timeout(30000); + + let freePorts; + let proxyServer; + + before(async () => { + freePorts = await portastic.find({ min: 52000, max: 52500 }); + }); + + afterEach(async () => { + if (proxyServer) { + await proxyServer.close(true); + proxyServer = null; + } + }); + + it('rejects TLS 1.0 clients', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + // Create HTTPS proxy with default TLS settings (minVersion: TLSv1.2) + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + minVersion: 'TLSv1', + maxVersion: 'TLSv1', + rejectUnauthorized: false, + }); + + expect(result.success).to.be.false; + expect(result.error).to.exist; + expect(result.error.code).to.match(/UNSUPPORTED_PROTOCOL|EPROTO|ECONNRESET|ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION/); + }); + + it('rejects TLS 1.1 clients', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + // Create HTTPS proxy with default TLS settings (minVersion: TLSv1.2) + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + minVersion: 'TLSv1.1', + maxVersion: 'TLSv1.1', + rejectUnauthorized: false, + }); + + expect(result.success).to.be.false; + expect(result.error).to.exist; + expect(result.error.code).to.match(/UNSUPPORTED_PROTOCOL|EPROTO|ECONNRESET|ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION/); + }); + + it('accepts TLS 1.2 clients', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.2', + rejectUnauthorized: false, + }); + + expect(result.success).to.be.true; + expect(result.protocol).to.equal('TLSv1.2'); + }); + + it('accepts TLS 1.3 clients', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + rejectUnauthorized: false, + }); + + expect(result.success).to.be.true; + expect(result.protocol).to.equal('TLSv1.3'); + }); + + it('accepts clients with strong ciphers', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + // Attempt connection with strong ciphers (AES-GCM, ChaCha20) + const strongCiphers = 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384'; + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + ciphers: strongCiphers, + rejectUnauthorized: false, + }); + + expect(result.success).to.be.true; + expect(result.cipher).to.exist; + expect(result.cipher.name).to.match(/AES.*GCM|CHACHA20/i); + }); +}); + +describe('HTTPS Edge Cases - SNI (Server Name Indication)', function () { + this.timeout(30000); + + let freePorts; + let targetServer; + let proxyServer; + + before(async () => { + freePorts = await portastic.find({ min: 52500, max: 53000 }); + }); + + afterEach(async () => { + if (proxyServer) { + await proxyServer.close(true); + proxyServer = null; + } + if (targetServer) { + await targetServer.close(); + targetServer = null; + } + }); + + it('sends correct SNI for HTTPS target through HTTPS proxy', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + // Verify certificate is for localhost (only on Node 15.6.0+) + if (supportsX509Certificate) { + expect(certificateMatchesHostname(validCert.cert, 'localhost')).to.be.true; + } + + // Create HTTPS proxy with certificate for localhost + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + // Test 1: Connect with correct SNI (localhost) - should succeed + const resultWithCorrectSNI = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + servername: 'localhost', // Correct SNI matching certificate + rejectUnauthorized: false, // Ignore self-signed cert, but SNI still validated + }); + + expect(resultWithCorrectSNI.success).to.be.true; + + // Test 2: Connect without SNI - should also succeed (TLS 1.2 compatibility) + const resultWithoutSNI = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + // No servername = no SNI extension + rejectUnauthorized: false, + }); + + expect(resultWithoutSNI.success).to.be.true; + }); + + it('handles SNI mismatch errors correctly', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + // Verify certificate is for localhost, not example.com (only on Node 15.6.0+) + if (supportsX509Certificate) { + expect(certificateMatchesHostname(validCert.cert, 'localhost')).to.be.true; + expect(certificateMatchesHostname(validCert.cert, 'example.com')).to.be.false; + } + + // Create HTTPS proxy with certificate for localhost + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + // Attempt TLS connection with mismatched SNI + // Server has cert for "localhost", but client sends SNI for "example.com" + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: proxyPort, + servername: 'example.com', // SNI mismatch! + rejectUnauthorized: true, // Strict validation + }); + + // Connection should fail due to SNI/hostname mismatch + expect(result.success).to.be.false; + expect(result.error).to.exist; + // Error can be certificate validation error or hostname mismatch + expect(result.error.code || result.error.message).to.match(/CERT|UNABLE_TO_VERIFY|self.*signed|ERR_TLS_CERT_ALTNAME_INVALID/i); + }); +}); + +/** + * Test TLS handshake with specific version and cipher configuration + * @returns {Promise} Result object with success status, protocol, cipher, or error + */ +const testTLSHandshake = async ({ + host, + port, + minVersion, + maxVersion, + ciphers, + servername, + rejectUnauthorized = false, + timeout = 5000, +}) => { + return new Promise((resolve) => { + const socket = tls.connect({ + host, + port, + minVersion, + maxVersion, + ciphers, + servername, + rejectUnauthorized, + }); + + let resolved = false; + + const cleanup = () => { + if (!resolved) { + resolved = true; + socket.destroy(); + } + }; + + socket.on('secureConnect', () => { + if (resolved) return; + resolved = true; + + const protocol = socket.getProtocol(); // 'TLSv1.2', 'TLSv1.3', etc. + const cipher = socket.getCipher(); // { name, version, standardName } + + socket.destroy(); + resolve({ + success: true, + protocol, + cipher, + }); + }); + + socket.on('error', (error) => { + if (resolved) return; + resolved = true; + + socket.destroy(); + resolve({ + success: false, + error: { + message: error.message, + code: error.code, + errno: error.errno, + }, + }); + }); + + socket.setTimeout(timeout, () => { + if (resolved) return; + cleanup(); + resolve({ + success: false, + error: { + message: 'Connection timeout', + code: 'ETIMEDOUT', + }, + }); + }); + }); +}; diff --git a/test/server.js b/test/server.js index 0eee8c06..07cc7f05 100644 --- a/test/server.js +++ b/test/server.js @@ -4,6 +4,7 @@ const path = require('path'); const stream = require('stream'); const childProcess = require('child_process'); const net = require('net'); +const tls = require('tls'); const dns = require('dns'); const util = require('util'); const { expect, assert } = require('chai'); @@ -75,14 +76,34 @@ const puppeteerGet = (url, proxyUrl) => { return (async () => { const parsed = proxyUrl ? new URL(proxyUrl) : undefined; - const browser = await puppeteer.launch({ - env: parsed ? { - HTTP_PROXY: parsed.origin, - } : {}, + const args = [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage' + ]; + + const launchOptions = { ignoreHTTPSErrors: true, headless: "new", - args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] - }); + args + }; + + // For HTTPS proxies, use --proxy-server flag (HTTP_PROXY doesn't support https:// URLs) + // For HTTP proxies, use HTTP_PROXY env var + if (parsed) { + if (parsed.protocol === 'https:') { + args.push(`--proxy-server=${parsed.origin}`); + // For HTTPS proxies with self-signed certificates, + // ignore certificate errors on the proxy connection itself + args.push('--ignore-certificate-errors'); + } else { + launchOptions.env = { + HTTP_PROXY: parsed.origin, + }; + } + } + + const browser = await puppeteer.launch(launchOptions); try { const page = await browser.newPage(); @@ -109,9 +130,15 @@ const puppeteerGet = (url, proxyUrl) => { // uncaught ECONNRESET error. See https://github.com/apify/proxy-chain/issues/53 // This is a regression test for that situation const curlGet = (url, proxyUrl, returnResponse) => { - let cmd = 'curl --insecure '; // ignore SSL errors - if (proxyUrl) cmd += `-x ${proxyUrl} `; // use proxy - if (returnResponse) cmd += `--silent --output - ${url}`; // print response to stdout + let cmd = 'curl --insecure '; // ignore SSL errors on target + if (proxyUrl) { + // Ignore SSL errors on proxy if it's HTTPS + if (proxyUrl.startsWith('https://')) { + cmd += '--proxy-insecure '; + } + cmd += `-x ${proxyUrl} `; // use proxy + } + if (returnResponse) cmd += `--silent --show-error --output - ${url}`; // print response to stdout else cmd += `${url}`; // console.log(`curlGet(): ${cmd}`); @@ -129,7 +156,7 @@ const curlGet = (url, proxyUrl, returnResponse) => { * @return {function(...[*]=)} */ const createTestSuite = ({ - useSsl, useMainProxy, mainProxyAuth, useUpstreamProxy, upstreamProxyAuth, testCustomResponse, + useSsl, useMainProxy, mainProxyAuth, mainProxyServerType, useUpstreamProxy, upstreamProxyAuth, testCustomResponse, }) => { return function () { this.timeout(30 * 1000); @@ -162,13 +189,21 @@ const createTestSuite = ({ let baseUrl; let mainProxyUrl; const getRequestOpts = (pathOrUrl) => { - return { + const opts = { url: pathOrUrl[0] === '/' ? `${baseUrl}${pathOrUrl}` : pathOrUrl, key: sslKey, proxy: mainProxyUrl, headers: {}, timeout: 30000, }; + + // Accept self-signed certificates when connecting to HTTPS proxy + if (mainProxyServerType === 'https') { + opts.strictSSL = false; + opts.rejectUnauthorized = false; + } + + return opts; }; let counter = 0; @@ -411,6 +446,15 @@ const createTestSuite = ({ opts.authRealm = AUTH_REALM; + // Configure HTTPS proxy server if requested + if (mainProxyServerType === 'https') { + opts.serverType = 'https'; + opts.httpsOptions = { + key: sslKey, + cert: sslCrt, + }; + } + mainProxyServer = new Server(opts); mainProxyServer.on('connectionClosed', ({ connectionId, stats }) => { @@ -437,7 +481,8 @@ const createTestSuite = ({ if (useMainProxy) { let auth = ''; if (mainProxyAuth) auth = `${mainProxyAuth.username}:${mainProxyAuth.password}@`; - mainProxyUrl = `http://${auth}127.0.0.1:${mainProxyServerPort}`; + const proxyScheme = mainProxyServerType === 'https' ? 'https' : 'http'; + mainProxyUrl = `${proxyScheme}://${auth}127.0.0.1:${mainProxyServerPort}`; } }); }); @@ -520,9 +565,12 @@ const createTestSuite = ({ upstreamProxyHostname = '127.0.0.1'; } }); - } else if (useMainProxy && process.versions.node.split('.')[0] >= 15) { + } else if (useMainProxy && process.versions.node.split('.')[0] >= 15 && mainProxyServerType !== 'https') { // Version check is required because HTTP/2 negotiation // is not supported on Node.js < 15. + // Note: Skipped for HTTPS proxy - got-scraping has issues with IPv6 + HTTPS proxy combination + // This appears to be a limitation in how got-scraping or its underlying HTTP client handles + // TLS connections to HTTPS proxies when using IPv6 addresses _it('direct ipv6', async () => { const opts = getRequestOpts('/hello-world'); @@ -545,9 +593,12 @@ const createTestSuite = ({ expect(response.body).to.eql('Hello world!'); expect(response.statusCode).to.eql(200); }); - } else if (!useSsl && process.versions.node.split('.')[0] >= 15) { + } else if (!useSsl && process.versions.node.split('.')[0] >= 15 && mainProxyServerType !== 'https') { // Version check is required because HTTP/2 negotiation // is not supported on Node.js < 15. + // Note: Skipped for HTTPS proxy - got-scraping has issues with IPv6 + HTTPS proxy combination + // This appears to be a limitation in how got-scraping or its underlying HTTP client handles + // TLS connections to HTTPS proxies when using IPv6 addresses _it('forward ipv6', async () => { const opts = getRequestOpts('/hello-world'); @@ -667,6 +718,7 @@ const createTestSuite = ({ }); if (!useSsl) { + // Note: Test handles both HTTP and HTTPS proxies (uses TLS wrapper for HTTPS) _it('handles double Host header', () => { // This is a regression test, duplication of Host headers caused the proxy to throw // "TypeError: hostHeader.startsWith is not a function" @@ -691,10 +743,25 @@ const createTestSuite = ({ + 'Host: dummy2.example.com\r\n\r\n'; } - const client = net.createConnection({ port }, () => { - // console.log('connected to server! sending msg: ' + httpMsg); - client.write(httpMsg); - }); + // Create appropriate connection based on proxy type + let client; + if (mainProxyServerType === 'https') { + // Use TLS connection for HTTPS proxy + client = tls.connect({ + port, + host: 'localhost', + rejectUnauthorized: false, // Accept self-signed certs + }, () => { + // console.log('TLS connected to server! sending msg: ' + httpMsg); + client.write(httpMsg); + }); + } else { + // Use raw TCP connection for HTTP proxy + client = net.createConnection({ port }, () => { + // console.log('connected to server! sending msg: ' + httpMsg); + client.write(httpMsg); + }); + } client.on('data', (data) => { // console.log('received data: ' + data.toString()); try { @@ -780,6 +847,51 @@ const createTestSuite = ({ }; _it('handles large GET response', test1MAChars); + // Regression test for connection stats tracking bug with HTTPS + upstream proxy + // Only run this test for HTTPS proxy + upstream proxy combinations + const shouldRunStatsTest = mainProxyServerType === 'https' && useUpstreamProxy; + (shouldRunStatsTest ? it : it.skip)('tracks connection stats correctly for HTTPS proxy with upstream', () => { + // This test specifically validates that stats are not undefined + // for HTTPS proxy + upstream proxy combinations + const opts = getRequestOpts('/hello-world'); + opts.method = 'GET'; + + return requestPromised(opts) + .then((response) => { + expect(response.body).to.eql('Hello world!'); + expect(response.statusCode).to.eql(200); + + // Wait a bit for connectionClosed event to fire + return new Promise(resolve => setTimeout(resolve, 100)); + }) + .then(() => { + // Verify that connection stats are available + if (mainProxyServerConnections && Object.keys(mainProxyServerConnections).length) { + const connectionIds = Object.keys(mainProxyServerConnections); + const lastConnectionId = connectionIds[connectionIds.length - 1]; + + const stats = mainProxyServer.getConnectionStats(Number(lastConnectionId)) + || mainProxyServerConnectionId2Stats[lastConnectionId]; + + // Stats should exist (this was the bug - stats was undefined) + expect(stats).to.not.be.undefined; + expect(stats).to.be.an('object'); + + // Stats should have valid numeric values + expect(stats.srcTxBytes).to.be.a('number'); + expect(stats.srcRxBytes).to.be.a('number'); + expect(stats.trgTxBytes).to.be.a('number'); + expect(stats.trgRxBytes).to.be.a('number'); + + // Stats should be non-negative + expect(stats.srcTxBytes).to.be.at.least(0); + expect(stats.srcRxBytes).to.be.at.least(0); + expect(stats.trgRxBytes).to.be.at.least(0); + expect(stats.trgRxBytes).to.be.at.least(0); + } + }); + }); + // TODO: Test streamed GET // _it('handles large streamed GET response', test1MAChars); @@ -837,7 +949,8 @@ const createTestSuite = ({ if (!useSsl && mainProxyAuth && mainProxyAuth.username && mainProxyAuth.password) { it('handles GET request using puppeteer with invalid credentials', async () => { const phantomUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; - const response = await puppeteerGet(phantomUrl, `http://bad:password@127.0.0.1:${mainProxyServerPort}`); + const proxyScheme = mainProxyServerType === 'https' ? 'https' : 'http'; + const response = await puppeteerGet(phantomUrl, `${proxyScheme}://bad:password@127.0.0.1:${mainProxyServerPort}`); expect(response).to.contain('Proxy credentials required'); }); } @@ -856,7 +969,8 @@ const createTestSuite = ({ it('handles GET request from curl with invalid credentials', async () => { const curlUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; // For SSL, we need to return curl's stderr to check what kind of error was there - const output = await curlGet(curlUrl, `http://bad:password@127.0.0.1:${mainProxyServerPort}`, !useSsl); + const proxyScheme = mainProxyServerType === 'https' ? 'https' : 'http'; + const output = await curlGet(curlUrl, `${proxyScheme}://bad:password@127.0.0.1:${mainProxyServerPort}`, !useSsl); if (useSsl) { expect(output).to.contain.oneOf([ // Old error message before dafdb20a26d0c890e83dea61a104b75408481ebd @@ -927,12 +1041,16 @@ const createTestSuite = ({ } it('handles invalid CONNECT path', (done) => { - const req = http.request(mainProxyUrl, { + // Use https.request for HTTPS proxy, http.request for HTTP proxy + const requestModule = mainProxyServerType === 'https' ? https : http; + const req = requestModule.request(mainProxyUrl, { method: 'CONNECT', path: ':443', headers: { host: ':443', }, + // Accept self-signed certificates for HTTPS proxy + rejectUnauthorized: false, }); req.once('connect', (response, socket, head) => { expect(response.statusCode).to.equal(400); @@ -985,14 +1103,28 @@ const createTestSuite = ({ }); server.listen(0, () => { - const req = http.request(mainProxyUrl, { + const proxyUrl = new URL(mainProxyUrl); + const requestModule = proxyUrl.protocol === 'https:' ? https : http; + + // Build complete options object - do NOT pass URL string separately + // to avoid Node.js URL parsing overriding the path option + const requestOpts = { + hostname: proxyUrl.hostname, + port: proxyUrl.port, method: 'CONNECT', path: `127.0.0.1:${server.address().port}`, headers: { host: `127.0.0.1:${server.address().port}`, 'proxy-authorization': `Basic ${Buffer.from('nopassword').toString('base64')}`, }, - }); + }; + + // Accept self-signed certificates when connecting to HTTPS proxy + if (proxyUrl.protocol === 'https:') { + requestOpts.rejectUnauthorized = false; + } + + const req = requestModule.request(requestOpts); req.once('connect', (response, socket, head) => { expect(response.statusCode).to.equal(200); expect(head.length).to.equal(0); @@ -1008,29 +1140,31 @@ const createTestSuite = ({ }); it('returns 407 for invalid credentials', () => { + const proxyScheme = mainProxyServerType === 'https' ? 'https' : 'http'; + return Promise.resolve() .then(() => { // Test no username and password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxyScheme}://127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then(() => { // Test good username and invalid password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://${mainProxyAuth.username}:bad-password@127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxyScheme}://${mainProxyAuth.username}:bad-password@127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then(() => { // Test invalid username and good password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://bad-username:${mainProxyAuth.password}@127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxyScheme}://bad-username:${mainProxyAuth.password}@127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then(() => { - // Test invalid username and good password + // Test invalid username and bad password const opts = getRequestOpts('/whatever'); - opts.proxy = `http://bad-username:bad-password@127.0.0.1:${mainProxyServerPort}`; + opts.proxy = `${proxyScheme}://bad-username:bad-password@127.0.0.1:${mainProxyServerPort}`; return testForErrorResponse(opts, 407); }) .then((response) => { @@ -1215,6 +1349,36 @@ const createTestSuite = ({ }; }; +describe('Server constructor - serverType', () => { + it('should default to "http" when serverType is not specified', async () => { + const server = new Server({ port: 0 }); + await server.listen(); + expect(server.serverType).to.equal('http'); + expect(server.server).to.be.instanceOf(http.Server); + await server.close(true); + }); + + it('should use "http" when explicitly specified', async () => { + const server = new Server({ port: 0, serverType: 'http' }); + await server.listen(); + expect(server.serverType).to.equal('http'); + expect(server.server).to.be.instanceOf(http.Server); + await server.close(true); + }); + + it('should use "https" when explicitly specified with httpsOptions', async () => { + const server = new Server({ + port: 0, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt } + }); + await server.listen(); + expect(server.serverType).to.equal('https'); + expect(server.server).to.be.instanceOf(https.Server); + await server.close(true); + }); +}); + describe('Test 0 port option', async () => { it('Port inherits net port', async () => { for (let i = 0; i < 10; i++) { @@ -1264,7 +1428,7 @@ describe('non-200 upstream connect response', () => { it('fails downstream with 590', (done) => { const server = http.createServer(); server.on('connect', (_request, socket) => { - socket.once('error', () => {}); + socket.once('error', () => { }); socket.end('HTTP/1.1 403 Forbidden\r\ncontent-length: 1\r\n\r\na'); }); server.listen(() => { @@ -1340,6 +1504,146 @@ it('supports localAddress', async () => { } }); +it('prevents duplicate socket error logging via proxyChainErrorHandled flag', async () => { + // This test verifies that the proxyChainErrorHandled flag prevents duplicate handling. + // When multiple error events fire on the same socket, only the first error is processed. + // Subsequent errors are ignored by checking the flag at the start of the error handler. + + const server = new Server({ + port: 0, + verbose: false, + }); + + await server.listen(); + + try { + // Create a raw socket connection to trigger the onConnection handler + const socket = await new Promise((resolve, reject) => { + const client = net.connect({ + host: '127.0.0.1', + port: server.port, + }, () => { + resolve(client); + }); + client.on('error', reject); + }); + + // Give Node.js time to register the socket and attach error handlers + await wait(100); + + // Get the server-side socket from the connection + const serverSockets = Array.from(server.connections.values()); + expect(serverSockets.length).to.equal(1); + const serverSocket = serverSockets[0]; + + // Verify the socket has been registered with an ID + expect(serverSocket.proxyChainId).to.be.a('number'); + + // Verify flag is not set initially + expect(serverSocket.proxyChainErrorHandled).to.be.undefined; + + // Emit multiple error events on the same socket + serverSocket.emit('error', new Error('Test error 1')); + serverSocket.emit('error', new Error('Test error 2')); + serverSocket.emit('error', new Error('Test error 3')); + + // Give time for all error handlers to fire + await wait(50); + + // The flag should be set after first error, preventing subsequent errors from being processed + expect(serverSocket.proxyChainErrorHandled).to.equal(true); + + socket.destroy(); + } finally { + await server.close(); + } +}); + +it('early error handler logs only when it is the sole error handler on socket', async () => { + // This test verifies the socket.listenerCount('error') === 1 logic + // by controlling the exact number of handlers and testing both branches + + const server = new Server({ + port: 0, + verbose: false, + }); + + await server.listen(); + + try { + let logCallCount = 0; + + // Monkey-patch server.log to track calls + const originalLog = server.log.bind(server); + server.log = (connectionId, message) => { + if (message.includes('Source socket emitted error:')) { + logCallCount++; + } + originalLog(connectionId, message); + }; + + const socket = await new Promise((resolve, reject) => { + const client = net.connect({ + host: '127.0.0.1', + port: server.port, + }, () => resolve(client)); + client.on('error', reject); + }); + + await wait(100); + + const serverSocket = Array.from(server.connections.values())[0]; + + // STAGE 1: Remove all existing handlers and add only our test handler + // This ensures we have exactly 1 handler (deterministic) + const existingHandlers = serverSocket.listeners('error'); + existingHandlers.forEach((handler) => serverSocket.removeListener('error', handler)); + + // Add our early error handler (same logic as in onConnection) + serverSocket.on('error', (err) => { + if (serverSocket.proxyChainErrorHandled) return; + serverSocket.proxyChainErrorHandled = true; + + if (serverSocket.listenerCount('error') === 1) { + server.log(serverSocket.proxyChainId, `Source socket emitted error: ${err.stack || err}`); + } + }); + + // Verify exactly 1 handler exists + expect(serverSocket.listenerCount('error')).to.equal(1); + + // Emit error when count === 1 + serverSocket.emit('error', new Error('Test with 1 handler')); + await wait(50); + + // Should have logged (count === 1) + expect(logCallCount).to.equal(1); + expect(serverSocket.proxyChainErrorHandled).to.equal(true); + + // STAGE 2: Add another handler, now count === 2 + serverSocket.on('error', () => { + // Secondary handler (doesn't use flag) + }); + + expect(serverSocket.listenerCount('error')).to.equal(2); + + // Reset flag to allow handler to execute again + serverSocket.proxyChainErrorHandled = false; + + // Emit error when count === 2 + serverSocket.emit('error', new Error('Test with 2 handlers')); + await wait(50); + + // Should NOT have logged again (count > 1) + expect(logCallCount).to.equal(1); // Still 1, not 2 + expect(serverSocket.proxyChainErrorHandled).to.equal(true); + + socket.destroy(); + } finally { + await server.close(); + } +}); + it('supports https proxy relay', async () => { const target = https.createServer(() => { }); @@ -1579,6 +1883,10 @@ describe('supports ignoreUpstreamProxyCertificate', () => { }); // Run all combinations of test parameters +const mainProxyServerTypeVariants = [ + 'http', + 'https', +]; const useSslVariants = [ false, true, @@ -1601,48 +1909,53 @@ const upstreamProxyAuthVariants = [ { type: 'Basic', username: 'us%erB', password: 'p$as%sA' }, ]; -useSslVariants.forEach((useSsl) => { - mainProxyAuthVariants.forEach((mainProxyAuth) => { - const baseDesc = `Server (${useSsl ? 'HTTPS' : 'HTTP'} -> Main proxy`; - - // Test custom response separately (it doesn't use upstream proxies) - describe(`${baseDesc} -> Target + custom responses)`, createTestSuite({ - useMainProxy: true, - useSsl, - mainProxyAuth, - testCustomResponse: true, - })); - - useUpstreamProxyVariants.forEach((useUpstreamProxy) => { - // If useUpstreamProxy is not used, only try one variant of upstreamProxyAuth - let variants = upstreamProxyAuthVariants; - if (!useUpstreamProxy) variants = [null]; - - variants.forEach((upstreamProxyAuth) => { - let desc = `${baseDesc} `; - - if (mainProxyAuth) { - if (!mainProxyAuth) desc += 'public '; - else if (mainProxyAuth.username && mainProxyAuth.password) desc += 'with username:password '; - else if (mainProxyAuth.username) desc += 'with username only '; - else desc += 'with password only '; - } - if (useUpstreamProxy) { - desc += '-> Upstream proxy '; - if (!upstreamProxyAuth) desc += 'public '; - else if (upstreamProxyAuth.username && upstreamProxyAuth.password) desc += 'with username:password '; - else if (upstreamProxyAuth.username) desc += 'with username only '; - else desc += 'with password only '; - } - desc += '-> Target)'; - - describe(desc, createTestSuite({ - useMainProxy: true, - useSsl, - useUpstreamProxy, - mainProxyAuth, - upstreamProxyAuth, - })); +mainProxyServerTypeVariants.forEach((mainProxyServerType) => { + useSslVariants.forEach((useSsl) => { + mainProxyAuthVariants.forEach((mainProxyAuth) => { + const proxyTypeLabel = mainProxyServerType === 'https' ? 'HTTPS' : 'HTTP'; + const baseDesc = `Server (${useSsl ? 'HTTPS' : 'HTTP'} -> ${proxyTypeLabel} Main proxy`; + + // Test custom response separately (it doesn't use upstream proxies) + describe(`${baseDesc} -> Target + custom responses)`, createTestSuite({ + useMainProxy: true, + useSsl, + mainProxyAuth, + mainProxyServerType, + testCustomResponse: true, + })); + + useUpstreamProxyVariants.forEach((useUpstreamProxy) => { + // If useUpstreamProxy is not used, only try one variant of upstreamProxyAuth + let variants = upstreamProxyAuthVariants; + if (!useUpstreamProxy) variants = [null]; + + variants.forEach((upstreamProxyAuth) => { + let desc = `${baseDesc} `; + + if (mainProxyAuth) { + if (!mainProxyAuth) desc += 'public '; + else if (mainProxyAuth.username && mainProxyAuth.password) desc += 'with username:password '; + else if (mainProxyAuth.username) desc += 'with username only '; + else desc += 'with password only '; + } + if (useUpstreamProxy) { + desc += '-> Upstream proxy '; + if (!upstreamProxyAuth) desc += 'public '; + else if (upstreamProxyAuth.username && upstreamProxyAuth.password) desc += 'with username:password '; + else if (upstreamProxyAuth.username) desc += 'with username only '; + else desc += 'with password only '; + } + desc += '-> Target)'; + + describe(desc, createTestSuite({ + useMainProxy: true, + useSsl, + useUpstreamProxy, + mainProxyAuth, + mainProxyServerType, + upstreamProxyAuth, + })); + }); }); }); }); diff --git a/test/socks.js b/test/socks.js index 2a9e8dc8..55bad12d 100644 --- a/test/socks.js +++ b/test/socks.js @@ -1,101 +1,187 @@ +const fs = require('fs'); +const path = require('path'); const portastic = require('portastic'); const socksv5 = require('socksv5'); const { gotScraping } = require('got-scraping'); const { expect } = require('chai'); const ProxyChain = require('../src/index'); +// Load SSL certificates for HTTPS proxy server testing +const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key')); +const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt')); + +// Test both HTTP and HTTPS proxy server types with SOCKS upstream +const serverTypes = ['http', 'https']; + describe('SOCKS protocol', () => { - let socksServer; - let proxyServer; - let anonymizeProxyUrl; - - afterEach(() => { - if (socksServer) socksServer.close(); - if (proxyServer) proxyServer.close(); - if (anonymizeProxyUrl) ProxyChain.closeAnonymizedProxy(anonymizeProxyUrl, true); - }); + serverTypes.forEach((serverType) => { + describe(`Server (${serverType.toUpperCase()}) with SOCKS upstream`, () => { + let socksServer; + let proxyServer; + let anonymizeProxyUrl; - it('works without auth', (done) => { - portastic.find({ min: 50000, max: 50250 }).then((ports) => { - const [socksPort, proxyPort] = ports; - socksServer = socksv5.createServer((info, accept) => { - accept(); + afterEach(() => { + if (socksServer) socksServer.close(); + if (proxyServer) proxyServer.close(); + if (anonymizeProxyUrl) ProxyChain.closeAnonymizedProxy(anonymizeProxyUrl, true); }); - socksServer.listen(socksPort, '0.0.0.0', () => { - socksServer.useAuth(socksv5.auth.None()); - - proxyServer = new ProxyChain.Server({ - port: proxyPort, - prepareRequestFunction() { - return { - upstreamProxyUrl: `socks://127.0.0.1:${socksPort}`, + + it('works without auth', (done) => { + portastic.find({ min: 50000, max: 50250 }).then((ports) => { + const [socksPort, proxyPort] = ports; + socksServer = socksv5.createServer((_, accept) => { + accept(); + }); + socksServer.listen(socksPort, '0.0.0.0', () => { + socksServer.useAuth(socksv5.auth.None()); + + const serverOpts = { + port: proxyPort, + prepareRequestFunction() { + return { + upstreamProxyUrl: `socks://127.0.0.1:${socksPort}`, + }; + }, }; - }, - }); - proxyServer.listen(() => { - gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }) - .then((response) => { - expect(response.body).to.contain('Example Domain'); - done(); - }) - .catch(done); + + // Configure HTTPS proxy server if requested + if (serverType === 'https') { + serverOpts.serverType = 'https'; + serverOpts.httpsOptions = { + key: sslKey, + cert: sslCrt, + }; + } + + proxyServer = new ProxyChain.Server(serverOpts); + proxyServer.listen(() => { + const proxyScheme = serverType === 'https' ? 'https' : 'http'; + const proxyUrl = `${proxyScheme}://127.0.0.1:${proxyPort}`; + + const gotOpts = { + url: 'https://example.com', + proxyUrl, + }; + + // Accept self-signed certificates when connecting to HTTPS proxy + if (serverType === 'https') { + gotOpts.https = { rejectUnauthorized: false }; + } + + gotScraping.get(gotOpts) + .then((response) => { + expect(response.body).to.contain('Example Domain'); + done(); + }) + .catch(done); + }); + }); }); - }); - }); - }).timeout(10 * 1000); + }).timeout(10 * 1000); - it('work with auth', (done) => { - portastic.find({ min: 50250, max: 50500 }).then((ports) => { - const [socksPort, proxyPort] = ports; - socksServer = socksv5.createServer((info, accept) => { - accept(); - }); - socksServer.listen(socksPort, '0.0.0.0', () => { - socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { - cb(user === 'proxy-ch@in' && password === 'rules!'); - })); - - proxyServer = new ProxyChain.Server({ - port: proxyPort, - prepareRequestFunction() { - return { - upstreamProxyUrl: `socks://proxy-ch@in:rules!@127.0.0.1:${socksPort}`, + it('work with auth', (done) => { + portastic.find({ min: 50250, max: 50500 }).then((ports) => { + const [socksPort, proxyPort] = ports; + socksServer = socksv5.createServer((_, accept) => { + accept(); + }); + socksServer.listen(socksPort, '0.0.0.0', () => { + socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { + cb(user === 'proxy-ch@in' && password === 'rules!'); + })); + + const serverOpts = { + port: proxyPort, + prepareRequestFunction() { + return { + upstreamProxyUrl: `socks://proxy-ch@in:rules!@127.0.0.1:${socksPort}`, + }; + }, }; - }, - }); - proxyServer.listen(() => { - gotScraping.get({ url: 'https://example.com', proxyUrl: `http://127.0.0.1:${proxyPort}` }) - .then((response) => { - expect(response.body).to.contain('Example Domain'); - done(); - }) - .catch(done); + + // Configure HTTPS proxy server if requested + if (serverType === 'https') { + serverOpts.serverType = 'https'; + serverOpts.httpsOptions = { + key: sslKey, + cert: sslCrt, + }; + } + + proxyServer = new ProxyChain.Server(serverOpts); + proxyServer.listen(() => { + const proxyScheme = serverType === 'https' ? 'https' : 'http'; + const proxyUrl = `${proxyScheme}://127.0.0.1:${proxyPort}`; + + const gotOpts = { + url: 'https://example.com', + proxyUrl, + }; + + // Accept self-signed certificates when connecting to HTTPS proxy + if (serverType === 'https') { + gotOpts.https = { rejectUnauthorized: false }; + } + + gotScraping.get(gotOpts) + .then((response) => { + expect(response.body).to.contain('Example Domain'); + done(); + }) + .catch(done); + }); + }); }); - }); - }); - }).timeout(10 * 1000); + }).timeout(10 * 1000); - it('works with anonymizeProxy', (done) => { - portastic.find({ min: 50500, max: 50750 }).then((ports) => { - const [socksPort, proxyPort] = ports; - socksServer = socksv5.createServer((info, accept) => { - accept(); - }); - socksServer.listen(socksPort, '0.0.0.0', () => { - socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { - cb(user === 'proxy-ch@in' && password === 'rules!'); - })); - - ProxyChain.anonymizeProxy({ port: proxyPort, url: `socks://proxy-ch@in:rules!@127.0.0.1:${socksPort}` }).then((anonymizedProxyUrl) => { - anonymizeProxyUrl = anonymizedProxyUrl; - gotScraping.get({ url: 'https://example.com', proxyUrl: anonymizedProxyUrl }) - .then((response) => { - expect(response.body).to.contain('Example Domain'); - done(); - }) - .catch(done); + it('works with anonymizeProxy', (done) => { + portastic.find({ min: 50500, max: 50750 }).then((ports) => { + const [socksPort, proxyPort] = ports; + socksServer = socksv5.createServer((_, accept) => { + accept(); + }); + socksServer.listen(socksPort, '0.0.0.0', () => { + socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { + cb(user === 'proxy-ch@in' && password === 'rules!'); + })); + + const anonymizeOpts = { + port: proxyPort, + url: `socks://proxy-ch@in:rules!@127.0.0.1:${socksPort}`, + }; + + // Configure HTTPS proxy server if requested + if (serverType === 'https') { + anonymizeOpts.serverType = 'https'; + anonymizeOpts.httpsOptions = { + key: sslKey, + cert: sslCrt, + }; + } + + ProxyChain.anonymizeProxy(anonymizeOpts).then((anonymizedProxyUrl) => { + anonymizeProxyUrl = anonymizedProxyUrl; + + const gotOpts = { + url: 'https://example.com', + proxyUrl: anonymizedProxyUrl, + }; + + // Accept self-signed certificates when connecting to HTTPS proxy + if (serverType === 'https') { + gotOpts.https = { rejectUnauthorized: false }; + } + + gotScraping.get(gotOpts) + .then((response) => { + expect(response.body).to.contain('Example Domain'); + done(); + }) + .catch(done); + }); + }); }); - }); + }).timeout(10 * 1000); }); - }).timeout(10 * 1000); + }); }); diff --git a/test/tls_overhead_stats.js b/test/tls_overhead_stats.js new file mode 100644 index 00000000..8aaa1369 --- /dev/null +++ b/test/tls_overhead_stats.js @@ -0,0 +1,2657 @@ +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const https = require('https'); +const tls = require('tls'); +const { expect } = require('chai'); +const portastic = require('portastic'); +const request = require('request'); +const sinon = require('sinon'); +const WebSocket = require('ws'); +const socksv5 = require('socksv5'); + +const { Server } = require('../src/index'); +const { TargetServer } = require('./utils/target_server'); + +// Enable self-signed certificates +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + +const sslKey = fs.readFileSync(path.join(__dirname, 'ssl.key')); +const sslCrt = fs.readFileSync(path.join(__dirname, 'ssl.crt')); + +const requestPromised = (opts) => { + return new Promise((resolve, reject) => { + request(opts, (error, response, body) => { + if (error) { + return reject(error); + } + resolve({ response, body }); + }); + }); +}; + +const wait = (timeout) => new Promise((resolve) => setTimeout(resolve, timeout)); + +/** + * Helper class: HTTP server that sends responses in controllable chunks. + * Enables stats queries at specific lifecycle points during transfer. + */ +class ChunkedTargetServer { + constructor({ port, chunks, delayBetweenChunks = 500 }) { + this.port = port; + this.chunks = chunks; + this.delayBetweenChunks = delayBetweenChunks; + this.server = null; + } + + async listen() { + const http = require('http'); + this.server = http.createServer(async (_, res) => { + // Calculate total size for Content-Length header + const totalSize = this.chunks.reduce((sum, chunk) => { + const size = typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; + return sum + size; + }, 0); + + res.writeHead(200, { + 'Content-Type': 'text/plain', + 'Content-Length': totalSize, + }); + + // Send chunks with delays + for (let i = 0; i < this.chunks.length; i++) { + res.write(this.chunks[i]); + + // Add delay between chunks (but not after last chunk) + if (i < this.chunks.length - 1) { + await new Promise(resolve => setTimeout(resolve, this.delayBetweenChunks)); + } + } + + res.end(); + }); + + await new Promise((resolve) => this.server.listen(this.port, resolve)); + } + + async close() { + if (this.server) { + await new Promise((resolve) => this.server.close(resolve)); + } + } + + get address() { + return this.server ? this.server.address() : null; + } +} + +/** + * Captures a snapshot of connection stats at a specific moment. + * Returns null if connection doesn't exist. + */ +function captureStatsSnapshot(server, connectionId, label) { + const stats = server.getConnectionStats(connectionId); + if (!stats) return null; + + return { + label, + timestamp: Date.now(), + srcTxBytes: stats.srcTxBytes, + srcRxBytes: stats.srcRxBytes, + trgTxBytes: stats.trgTxBytes, + trgRxBytes: stats.trgRxBytes, + }; +} + +/** + * Creates HTTPS agent with TLS session caching disabled. + */ +function createNonCachingAgent(extraOptions = {}) { + return new https.Agent({ + maxCachedSessions: 0, + ...extraOptions, + }); +} + +/** + * Captures connection statistics when connection closes. + */ +function awaitConnectionStats(server) { + return new Promise((resolve) => { + server.once('connectionClosed', ({ stats }) => resolve(stats)); + }); +} + +/** + * Creates socket manipulator that corrupts _parent socket byte properties. + */ +function createParentCorruptor(corruptionType) { + const staticValues = { + 'undefined': undefined, + 'null': null, + 'string': '123', + }; + + return (socket) => { + if (!socket._parent) return; + + if (corruptionType === 'invalid-getter') { + // Create getters that return values less than TLS socket (invalid!) + Object.defineProperty(socket._parent, 'bytesWritten', { + get() { return Math.max(0, (socket.bytesWritten || 0) - 1000); }, + configurable: true, + }); + Object.defineProperty(socket._parent, 'bytesRead', { + get() { return Math.max(0, (socket.bytesRead || 0) - 1000); }, + configurable: true, + }); + } else { + // Set static invalid values + Object.defineProperty(socket._parent, 'bytesWritten', { + value: staticValues[corruptionType], + writable: true, + configurable: true, + }); + Object.defineProperty(socket._parent, 'bytesRead', { + value: staticValues[corruptionType], + writable: true, + configurable: true, + }); + } + }; +} + +/** + * Expected stats for "Hello World" response (controlled payload, fixed certs, no session caching) + * Exact values intentionally used for precise bug detection: + * - Normal: srcTxBytes = 2255 (with TLS overhead) + * - Fallback: srcTxBytes = 174 (no TLS overhead) + * - Broken: srcTxBytes = 1200 (partial - ranges would miss this!) + * If tests fail on Node.js upgrades, add tolerance then. + */ +const EXPECTED_HTTPS_STATS = { srcTxBytes: 2255, srcRxBytes: 528, trgTxBytes: 71, trgRxBytes: 174 }; +const EXPECTED_HTTP_STATS = { srcTxBytes: 174, srcRxBytes: 93, trgTxBytes: 71, trgRxBytes: 174 }; + +const EXPECTED_FALLBACK_STATS = EXPECTED_HTTP_STATS; // TLS overhead unavailable +const EXPECTED_NORMAL_STATS = EXPECTED_HTTPS_STATS; // TLS overhead tracked + +const EXPECTED_EVENT_PROPS = { reason: 'raw_socket_missing', hasParent: true, parentType: 'Socket' }; + +/** + * Expected stats for chunked responses (controlled payloads with deterministic sizes) + * Used in lifecycle and concurrent tests to validate exact byte counts + */ +const EXPECTED_CHUNKED_6KB_STATS = { srcTxBytes: 8226, srcRxBytes: 517, trgTxBytes: 60, trgRxBytes: 6123 }; // 2 chunks * 3KB +const EXPECTED_CHUNKED_20KB_STATS = { srcTxBytes: 22227, srcRxBytes: 517, trgTxBytes: 60, trgRxBytes: 20124 }; // 2 chunks * 10KB +const EXPECTED_CHUNKED_30KB_STATS = { srcTxBytes: 32249, srcRxBytes: 517, trgTxBytes: 60, trgRxBytes: 30124 }; // 3 chunks * 10KB + +/** + * Expected stats for TLS handshake-only (no HTTP request/response data) + * - srcTxBytes: Server sends TLS handshake to client (ServerHello, Certificate, etc.) + * - srcRxBytes: Server receives TLS handshake from client (ClientHello, etc.) + * - trgTxBytes/trgRxBytes: null (no target connection established) + */ +const EXPECTED_TLS_HANDSHAKE_ONLY_STATS = { srcTxBytes: 1641, srcRxBytes: 337, trgTxBytes: null, trgRxBytes: null }; + +/** + * Expected stats for keep-alive and separate connection tests (10 requests * 10KB each) + * Keep-alive: Single connection reused for 10 requests (1 TLS handshake) + * Separate: 10 separate connections (10 TLS handshakes) + */ +const EXPECTED_KEEPALIVE_10REQ_STATS = { srcTxBytes: 103799, srcRxBytes: 1503, trgTxBytes: 600, trgRxBytes: 101240 }; +const EXPECTED_SEPARATE_10REQ_TOTAL = { srcTxBytes: 122050, srcRxBytes: 5170, trgTxBytes: 600, trgRxBytes: 101240 }; + +/** + * Expected stats for SOCKS5 upstream scenarios - HTTPS proxy -> SOCKS5 -> HTTP target + * + * Source side (client<->proxy): Includes TLS overhead (handshake + encryption) + * Target side (proxy<->SOCKS<->target): Application-layer + SOCKS protocol overhead + * + * SOCKS5 protocol overhead (measured from Docker tests): + * - No auth: +2 bytes in trgTxBytes, +0 bytes in trgRxBytes (73 vs 71 base HTTP) + * - With auth: +23 bytes in trgTxBytes, +2 bytes in trgRxBytes (94 vs 71 base HTTP) + * Auth adds username/password exchange overhead + * + * Values captured from Docker test run on Node.js 18.20.8 + */ +const EXPECTED_SOCKS5_GET_NOAUTH_STATS = { srcTxBytes: 2252, srcRxBytes: 517, trgTxBytes: 73, trgRxBytes: 183 }; +const EXPECTED_SOCKS5_CONNECT_NOAUTH_STATS = { srcTxBytes: 2313, srcRxBytes: 595, trgTxBytes: 73, trgRxBytes: 183 }; +const EXPECTED_SOCKS5_GET_AUTH_STATS = { srcTxBytes: 2252, srcRxBytes: 517, trgTxBytes: 94, trgRxBytes: 185 }; + +describe('TLS Overhead Statistics', function() { + this.timeout(30000); + + let freePorts; + let targetServer; + let httpProxyServer; + let httpsProxyServer; + + before(async () => { + freePorts = await portastic.find({ min: 50000, max: 50500 }); + + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + const httpProxyPort = freePorts.shift(); + httpProxyServer = new Server({ + port: httpProxyPort, + serverType: 'http', + verbose: false, + }); + await httpProxyServer.listen(); + + const httpsProxyPort = freePorts.shift(); + httpsProxyServer = new Server({ + port: httpsProxyPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await httpsProxyServer.listen(); + }); + + after(async () => { + await wait(200); // Let all connections close + + if (httpProxyServer) { + expect(httpProxyServer.getConnectionIds()).to.be.deep.equal([]); + await httpProxyServer.close(true); + } + if (httpsProxyServer) { + expect(httpsProxyServer.getConnectionIds()).to.be.deep.equal([]); + await httpsProxyServer.close(true); + } + if (targetServer) { + await targetServer.close(); + } + }); + + describe('Direct mechanism validation', () => { + it('HTTPS proxy stats include TLS overhead (validated via exact byte counts)', async () => { + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + const statsPromise = awaitConnectionStats(httpsProxyServer); + const agent = createNonCachingAgent(); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${httpsProxyServer.port}`, + strictSSL: false, + rejectUnauthorized: false, + agent, + }); + + const httpsStats = await statsPromise; + expect(httpsStats).to.not.be.null; + expect(httpsStats).to.deep.include(EXPECTED_HTTPS_STATS); + }); + + it('HTTP proxy stats are application-layer only (no TLS overhead)', async () => { + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + const statsPromise = awaitConnectionStats(httpProxyServer); + + await requestPromised({ + url: targetUrl, + proxy: `http://127.0.0.1:${httpProxyServer.port}`, + }); + + const httpStats = await statsPromise; + expect(httpStats).to.not.be.null; + expect(httpStats).to.deep.include(EXPECTED_HTTP_STATS); + }); + }); + + // TODO: should be fixed after https://github.com/apify/proxy-chain/pull/607 (count TLS overhead bytes for HTTPS upstreams) + it('target bytes should be similar for HTTP and HTTPS proxy (no TLS to target)', async () => { + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + const httpStatsPromise = awaitConnectionStats(httpProxyServer); + + await requestPromised({ + url: targetUrl, + proxy: `http://127.0.0.1:${httpProxyServer.port}`, + }); + const httpStats = await httpStatsPromise; + + const httpsStatsPromise = awaitConnectionStats(httpsProxyServer); + const agent = createNonCachingAgent(); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${httpsProxyServer.port}`, + strictSSL: false, + rejectUnauthorized: false, + agent, + }); + const httpsStats = await httpsStatsPromise; + + expect(httpStats).to.not.be.null; + expect(httpsStats).to.not.be.null; + + // Target bytes should be application-layer only (no TLS overhead) + // Both proxies connect to the same HTTP target (no TLS to target) + expect(httpStats.trgTxBytes).to.not.be.null; + expect(httpsStats.trgTxBytes).to.not.be.null; + + expect(httpStats.trgTxBytes).to.be.equal(httpsStats.trgTxBytes); + expect(httpStats.trgRxBytes).to.be.equal(httpsStats.trgRxBytes); + }); + + describe('Handling connections with missing _parent socket property', () => { + // API stability tests: simulate Node.js breaking socket._parent in future versions + // If implementation stops using _parent, these tests can be removed + + let fallbackTestProxy; + let socketManipulator; + + beforeEach(async () => { + socketManipulator = null; + + // Create fresh proxy for each test to ensure test isolation and prevent state pollution + const fallbackTestPort = freePorts.shift(); + fallbackTestProxy = new Server({ + port: fallbackTestPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await fallbackTestProxy.listen(); + }); + + afterEach(async () => { + if (fallbackTestProxy) { + if (socketManipulator && fallbackTestProxy.server) { + fallbackTestProxy.server.removeListener('secureConnection', socketManipulator); + } + await fallbackTestProxy.close(true); + fallbackTestProxy = null; + } + await wait(200); // Let connections fully close + }); + + // Helper function to install socket manipulator BEFORE connection processing + const installSocketManipulator = (manipulator) => { + socketManipulator = manipulator; + // Use prependListener to ensure our manipulator runs BEFORE onConnection + fallbackTestProxy.server.prependListener('secureConnection', socketManipulator); + }; + + it('handles missing _parent property (undefined)', async () => { + // Scenario: socket._parent byte properties are undefined + // Expected: Event fires, stats fallback to application-layer bytes + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + let unavailableEvent = null; + fallbackTestProxy.once('tlsOverheadUnavailable', (data) => { + unavailableEvent = data; + }); + + const statsPromise = awaitConnectionStats(fallbackTestProxy); + + installSocketManipulator(createParentCorruptor('undefined')); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${fallbackTestProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + expect(unavailableEvent).to.not.be.null; + expect(unavailableEvent).to.deep.include(EXPECTED_EVENT_PROPS); + expect(unavailableEvent.connectionId).to.be.a('number'); + + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + it('handles _parent byte properties set to null', async () => { + // Scenario: socket._parent byte properties are explicitly null + // Expected: Type check fails (typeof null !== 'number'), fallback occurs + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + let unavailableEvent = null; + fallbackTestProxy.once('tlsOverheadUnavailable', (data) => { + unavailableEvent = data; + }); + + const statsPromise = awaitConnectionStats(fallbackTestProxy); + + installSocketManipulator(createParentCorruptor('null')); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${fallbackTestProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + expect(unavailableEvent).to.not.be.null; + expect(unavailableEvent).to.deep.include(EXPECTED_EVENT_PROPS); + expect(unavailableEvent.connectionId).to.be.a('number'); + + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + it('handles _parent missing bytesWritten property', async () => { + // Scenario: _parent exists but lacks bytesWritten property + // Expected: Event fires with hasParent=true, fallback occurs + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + let unavailableEvent = null; + fallbackTestProxy.once('tlsOverheadUnavailable', (data) => { + unavailableEvent = data; + }); + + const statsPromise = new Promise((resolve) => { + fallbackTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + installSocketManipulator(createParentCorruptor('undefined')); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${fallbackTestProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + expect(unavailableEvent).to.not.be.null; + expect(unavailableEvent).to.deep.include(EXPECTED_EVENT_PROPS); + expect(unavailableEvent.connectionId).to.be.a('number'); + + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + it('handles _parent missing bytesRead property', async () => { + // Scenario: _parent exists but lacks bytesRead property + // Expected: Similar to missing bytesWritten + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + let unavailableEvent = null; + fallbackTestProxy.once('tlsOverheadUnavailable', (data) => { + unavailableEvent = data; + }); + + const statsPromise = new Promise((resolve) => { + fallbackTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + installSocketManipulator(createParentCorruptor('undefined')); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${fallbackTestProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + expect(unavailableEvent).to.not.be.null; + expect(unavailableEvent).to.deep.include(EXPECTED_EVENT_PROPS); + expect(unavailableEvent.connectionId).to.be.a('number'); + + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + it('handles _parent with invalid property types', async () => { + // Scenario: _parent exists but properties are not numbers + // Expected: Type check fails, fallback occurs + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + let unavailableEvent = null; + fallbackTestProxy.once('tlsOverheadUnavailable', (data) => { + unavailableEvent = data; + }); + + const statsPromise = new Promise((resolve) => { + fallbackTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + installSocketManipulator(createParentCorruptor('string')); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${fallbackTestProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + expect(unavailableEvent).to.not.be.null; + expect(unavailableEvent).to.deep.include(EXPECTED_EVENT_PROPS); + expect(unavailableEvent.connectionId).to.be.a('number'); + + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + it('falls back to TLS socket bytes when raw socket reports fewer bytes than TLS socket', async () => { + // Scenario: _parent exists with valid properties, but byte counts are inconsistent (raw < TLS) + // Expected: Stats fallback to TLS socket bytes + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + // Capture connectionClosed event to access socket + const statsPromise = new Promise((resolve) => { + fallbackTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + installSocketManipulator(createParentCorruptor('invalid-getter')); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${fallbackTestProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + it('logs warning when raw socket reports fewer bytes than TLS socket', async () => { + // Scenario: _parent exists with valid properties, but byte counts are inconsistent (raw < TLS) + // Expected: Warning logged for monitoring, stats fallback to TLS socket bytes + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + // Create a separate verbose server for log capture + const verboseProxyPort = freePorts.shift(); + const verboseProxyServer = new Server({ + port: verboseProxyPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: true, // CRITICAL: Enable logging for this test + }); + await verboseProxyServer.listen(); + + const consoleLogSpy = sinon.spy(console, 'log'); + + const statsPromise = new Promise((resolve) => { + verboseProxyServer.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + // Install socket manipulator on verbose server + let manipulatorInstalled = false; + const manipulator = (socket) => { + if (!manipulatorInstalled && socket._parent) { + manipulatorInstalled = true; + + // Override byte properties with getters that return invalid values + // This creates inconsistent byte counts: rawSocket.bytes < socket.bytes + Object.defineProperty(socket._parent, 'bytesWritten', { + get() { + // Return less than TLS socket bytes (invalid!) + return Math.max(0, (socket.bytesWritten || 0) - 1000); + }, + configurable: true, + }); + Object.defineProperty(socket._parent, 'bytesRead', { + get() { + // Return less than TLS socket bytes (invalid!) + return Math.max(0, (socket.bytesRead || 0) - 1000); + }, + configurable: true, + }); + } + }; + verboseProxyServer.server.prependListener('secureConnection', manipulator); + + try { + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${verboseProxyPort}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + + expect(consoleLogSpy.called).to.be.true; + + // Find the warning log in all console.log calls + const logCalls = consoleLogSpy.getCalls(); + const warningLog = logCalls.find(call => + call.args[0] && call.args[0].includes('Warning: TLS overhead count error') + ); + + expect(warningLog, 'Warning log should be emitted').to.exist; + expect(warningLog.args[0]).to.be.a('string'); + expect(warningLog.args[0]).to.include('TLS overhead count error'); + expect(warningLog.args[0]).to.match(/ProxyServer.*TLS overhead count error/); + } finally { + // Cleanup: Restore spy and close verbose server + consoleLogSpy.restore(); + verboseProxyServer.server.removeListener('secureConnection', manipulator); + await verboseProxyServer.close(true); + } + }); + + it('handles multiple connections with mixed _parent states', async () => { + // Scenario: Some connections have valid _parent, others don't + // Expected: Each connection handled independently, no cross-contamination + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + // Counter to alternate between valid and invalid _parent + let connectionCount = 0; + + // Capture all events + const unavailableEvents = []; + const allStats = []; + + const eventListener = (data) => { + unavailableEvents.push(data); + }; + fallbackTestProxy.on('tlsOverheadUnavailable', eventListener); + + const statsListener = ({ stats }) => { + allStats.push(stats); + }; + fallbackTestProxy.on('connectionClosed', statsListener); + + // Install manipulator that alternates behavior + const corruptorFn = createParentCorruptor('undefined'); + installSocketManipulator((socket) => { + connectionCount++; + const willCorrupt = connectionCount % 2 === 0; + if (willCorrupt) { + corruptorFn(socket); + } + // Odd connections: Leave _parent intact (normal behavior) + }); + + // Make 4 requests (2 normal, 2 with invalid byte properties) + // Important: Disable TLS session caching to ensure each connection performs + // a full TLS handshake. Without this, Node.js will reuse sessions causing + // connection #3 to show resumed session bytes (445) instead of full handshake (2255). + for (let i = 0; i < 4; i++) { + const agent = createNonCachingAgent(); + + const closurePromise = new Promise((resolve) => { + fallbackTestProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${fallbackTestProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + agent, + headers: { + 'Connection': 'close', + }, + }); + + await closurePromise; // Wait for connection to close + } + + expect(allStats.length).to.be.equal(4, 'Should have precisely 4 connections'); + + expect(unavailableEvents.length).to.be.equal(2, 'Should have precisely 2 unavailable event'); + unavailableEvents.forEach((event) => { + expect(event).to.deep.include(EXPECTED_EVENT_PROPS); + expect(event.connectionId).to.be.a('number'); + }); + + // Separate normal and fallback stats + const normalStats = allStats.filter((s) => s.srcTxBytes > 1000); // Has TLS overhead + const fallbackStats = allStats.filter((s) => s.srcTxBytes < 1000); // No TLS overhead + + // With session caching disabled, we should have exactly 2 normal and 2 fallback + expect(normalStats.length).to.equal(2, 'Should have exactly 2 normal connections'); + expect(fallbackStats.length).to.equal(2, 'Should have exactly 2 fallback connections'); + + // Cross-validation: events should match fallback stats + expect(unavailableEvents.length).to.equal(fallbackStats.length, 'Unavailable events should match fallback connections (both should be 2)'); + + normalStats.forEach((s) => { + expect(s).to.deep.include(EXPECTED_NORMAL_STATS); + }); + fallbackStats.forEach((s) => { + expect(s).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + fallbackTestProxy.removeListener('tlsOverheadUnavailable', eventListener); + fallbackTestProxy.removeListener('connectionClosed', statsListener); + }); + }); + + describe('Monitoring TLS overhead availability events', () => { + // Public API observability tests: event timing, lifecycle, multi-connection monitoring + + let eventMonitoringProxy; + let socketManipulator; + + beforeEach(async () => { + socketManipulator = null; + + // Create fresh proxy for each test to ensure test isolation and prevent state pollution + const eventMonitoringPort = freePorts.shift(); + eventMonitoringProxy = new Server({ + port: eventMonitoringPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await eventMonitoringProxy.listen(); + }); + + afterEach(async () => { + if (eventMonitoringProxy) { + if (socketManipulator && eventMonitoringProxy.server) { + eventMonitoringProxy.server.removeListener('secureConnection', socketManipulator); + } + await eventMonitoringProxy.close(true); + eventMonitoringProxy = null; + } + await wait(200); // Let connections fully close + }); + + // Helper function to install socket manipulator BEFORE connection processing + const installSocketManipulator = (manipulator) => { + socketManipulator = manipulator; + // Use prependListener to ensure our manipulator runs BEFORE onConnection + eventMonitoringProxy.server.prependListener('secureConnection', socketManipulator); + }; + + it('does not emit event when TLS overhead tracking is available (negative test)', async () => { + // Scenario: Normal TLS overhead tracking with valid _parent socket + // Expected: No tlsOverheadUnavailable event emission + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + let eventEmitted = false; + let capturedEvent = null; + const eventListener = (data) => { + eventEmitted = true; + capturedEvent = data; + }; + eventMonitoringProxy.once('tlsOverheadUnavailable', eventListener); + + const statsPromise = new Promise((resolve) => { + eventMonitoringProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + // No socket manipulation - _parent should work normally + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${eventMonitoringProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + const stats = await statsPromise; + + // Event should NOT have been emitted + expect(eventEmitted).to.be.false; + expect(capturedEvent).to.be.null; + + // Stats should show TLS overhead (normal operation) + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_NORMAL_STATS); + }); + + it('emits event exactly once per connection (not on each stats query)', async () => { + // Scenario: Connection with missing _parent, stats queried multiple times during lifecycle + // Expected: Event emitted exactly once, not on each stats query + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + // Track event emissions + let eventCount = 0; + let capturedEvent = null; + const eventListener = (data) => { + eventCount++; + capturedEvent = data; + }; + eventMonitoringProxy.on('tlsOverheadUnavailable', eventListener); + + let connectionId = null; + let statsQueryCount = 0; + + eventMonitoringProxy.once('tlsOverheadUnavailable', (data) => { + connectionId = data.connectionId; + }); + + const statsPromise = new Promise((resolve) => { + eventMonitoringProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + installSocketManipulator(createParentCorruptor('undefined')); + + const requestPromise = requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${eventMonitoringProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + await wait(100); + + // Query stats multiple times if we have connectionId + if (connectionId !== null) { + for (let i = 0; i < 3; i++) { + eventMonitoringProxy.getConnectionStats(connectionId); + statsQueryCount++; + await wait(10); + } + } + + await requestPromise; + const stats = await statsPromise; + + expect(eventCount).to.equal(1, 'Event should be emitted exactly once per connection'); + expect(capturedEvent).to.not.be.null; + expect(capturedEvent).to.deep.include(EXPECTED_EVENT_PROPS); + expect(capturedEvent.connectionId).to.be.a('number'); + + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + + eventMonitoringProxy.removeListener('tlsOverheadUnavailable', eventListener); + }); + + it('emits event during connection registration, not stats retrieval', async () => { + // Scenario: Track timing of event emission vs first stats query + // Expected: Event fires during connection registration, before any stats retrieval + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + // Track event and stats query timing + let eventTimestamp = null; + let firstStatsQueryTimestamp = null; + let connectionId = null; + + const eventListener = (data) => { + eventTimestamp = Date.now(); + connectionId = data.connectionId; + }; + eventMonitoringProxy.once('tlsOverheadUnavailable', eventListener); + + const statsPromise = new Promise((resolve) => { + eventMonitoringProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + installSocketManipulator(createParentCorruptor('undefined')); + + const requestPromise = requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${eventMonitoringProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + }); + + await wait(100); + + // Now query stats (this should happen AFTER event was emitted) + if (connectionId !== null) { + firstStatsQueryTimestamp = Date.now(); + eventMonitoringProxy.getConnectionStats(connectionId); + } + + await requestPromise; + const stats = await statsPromise; + + // Event should fire before first stats query + expect(eventTimestamp).to.not.be.null; + expect(firstStatsQueryTimestamp).to.not.be.null; + expect(eventTimestamp).to.be.lessThan( + firstStatsQueryTimestamp, + 'Event should be emitted during connection registration, before stats queries' + ); + + expect(stats).to.not.be.null; + expect(stats).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + it('supports monitoring events across multiple connections with mixed states', async () => { + // Scenario: Multiple connections with alternating normal and fallback states + // Expected: Events emitted only for fallback connections, stats tracked correctly for all + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + const allEvents = []; + const allStats = []; + + const eventListener = (data) => { + allEvents.push({ ...data, timestamp: Date.now() }); + }; + eventMonitoringProxy.on('tlsOverheadUnavailable', eventListener); + + const statsListener = ({ stats }) => { + allStats.push(stats); + }; + eventMonitoringProxy.on('connectionClosed', statsListener); + + // Counter to alternate between normal and fallback connections + let connectionCount = 0; + + // Install manipulator that creates alternating pattern: 3 normal + 3 fallback + const corruptorFn = createParentCorruptor('undefined'); + installSocketManipulator((socket) => { + connectionCount++; + // Pattern: odd = normal, even = fallback + const shouldCorrupt = connectionCount % 2 === 0; + if (shouldCorrupt) { + corruptorFn(socket); + } + }); + + // Make 6 requests (3 normal, 3 fallback) + for (let i = 0; i < 6; i++) { + const agent = createNonCachingAgent(); + + const closurePromise = new Promise((resolve) => { + eventMonitoringProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${eventMonitoringProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + agent, + headers: { + 'Connection': 'close', + }, + }); + + await closurePromise; // Wait for connection to close + } + + // Should have exactly 3 events (one per fallback connection) + expect(allStats.length).to.equal(6, 'Should have 6 total connections'); + expect(allEvents.length).to.equal(3, 'Should have exactly 3 events for 3 fallback connections'); + + const eventConnectionIds = allEvents.map(e => e.connectionId); + const uniqueConnectionIds = [...new Set(eventConnectionIds)]; + expect(uniqueConnectionIds).to.have.lengthOf(3, 'Each event should have unique connectionId'); + + allEvents.forEach((event) => { + expect(event).to.deep.include(EXPECTED_EVENT_PROPS); + expect(event.connectionId).to.be.a('number'); + }); + + // Separate normal and fallback stats + const normalStats = allStats.filter(s => s.srcTxBytes > 1000); + const fallbackStats = allStats.filter(s => s.srcTxBytes < 1000); + + expect(normalStats.length).to.equal(3, 'Should have 3 normal connections'); + expect(fallbackStats.length).to.equal(3, 'Should have 3 fallback connections'); + + normalStats.forEach(s => { + expect(s).to.deep.include(EXPECTED_NORMAL_STATS); + }); + fallbackStats.forEach(s => { + expect(s).to.deep.include(EXPECTED_FALLBACK_STATS); + }); + + expect(allEvents.length).to.equal(fallbackStats.length, 'Events should match fallback connections'); + + eventMonitoringProxy.removeListener('tlsOverheadUnavailable', eventListener); + eventMonitoringProxy.removeListener('connectionClosed', statsListener); + }); + + it('handles event listener lifecycle (add/remove/re-add)', async () => { + // Scenario: Event listeners dynamically added, removed, and re-added during operation + // Expected: Event listeners work correctly through the full lifecycle + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + // Track event captures + let firstEventCaptured = false; + let secondEventCaptured = false; + let thirdEventCaptured = false; + + installSocketManipulator(createParentCorruptor('undefined')); + + // Add first listener and make request + const firstListener = () => { + firstEventCaptured = true; + }; + eventMonitoringProxy.once('tlsOverheadUnavailable', firstListener); + + let closurePromise = new Promise((resolve) => { + eventMonitoringProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${eventMonitoringProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + agent: createNonCachingAgent(), + headers: { 'Connection': 'close' }, + }); + await closurePromise; + + // Remove listener (implicitly removed by 'once') and make second request + // The 'once' listener was already removed, so we explicitly test with no listener + closurePromise = new Promise((resolve) => { + eventMonitoringProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${eventMonitoringProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + agent: createNonCachingAgent(), + headers: { 'Connection': 'close' }, + }); + await closurePromise; + + // Re-add listener and make third request + const thirdListener = () => { + thirdEventCaptured = true; + }; + eventMonitoringProxy.once('tlsOverheadUnavailable', thirdListener); + + closurePromise = new Promise((resolve) => { + eventMonitoringProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${eventMonitoringProxy.port}`, + strictSSL: false, + rejectUnauthorized: false, + agent: createNonCachingAgent(), + headers: { 'Connection': 'close' }, + }); + await closurePromise; + + // Listener lifecycle worked correctly + expect(firstEventCaptured).to.be.true; + expect(secondEventCaptured).to.be.false; // No listener was active + expect(thirdEventCaptured).to.be.true; + }); + }); + + describe('Tracking statistics for failed TLS handshakes', () => { + // This test suite validates that failed TLS handshakes are properly excluded from + // connection statistics. Failed handshakes never reach secureConnection event, + // so they never get registered in the connections Map. + // + // NOTE: tlsError event emission is platform-dependent (Node.js version, TLS implementation) + // and is NOT tested here. Only core behavior (connection tracking) is validated. + + const tls = require('tls'); + + let tlsHandshakeTestProxy; + + // Helper function to test TLS handshake with configurable parameters + const testTLSHandshake = async ({ + host, + port, + minVersion, + maxVersion, + ciphers, + rejectUnauthorized = false, + timeout = 5000, + }) => { + return new Promise((resolve) => { + const socket = tls.connect({ + host, + port, + minVersion, + maxVersion, + ciphers, + rejectUnauthorized, + }); + + socket.on('secureConnect', async () => { + const protocol = socket.getProtocol(); + const cipher = socket.getCipher(); + + // Wait a bit before destroying to let connection registration complete + await new Promise(r => setTimeout(r, 50)); + + socket.destroy(); + resolve({ + success: true, + protocol, + cipher, + }); + }); + + socket.on('error', (error) => { + socket.destroy(); + resolve({ + success: false, + error: { + message: error.message, + code: error.code, + errno: error.errno, + }, + }); + }); + + socket.setTimeout(timeout, () => { + socket.destroy(); + resolve({ + success: false, + error: { + message: 'Connection timeout', + code: 'ETIMEDOUT', + }, + }); + }); + }); + }; + + beforeEach(async () => { + // Create a fresh HTTPS proxy server for each test + const tlsHandshakeTestPort = freePorts.shift(); + tlsHandshakeTestProxy = new Server({ + port: tlsHandshakeTestPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await tlsHandshakeTestProxy.listen(); + }); + + afterEach(async () => { + if (tlsHandshakeTestProxy) { + await tlsHandshakeTestProxy.close(true); + tlsHandshakeTestProxy = null; + } + await wait(200); // Let connections fully close + }); + + it('does not track failed TLS handshakes in connection stats', async () => { + // Scenario: TLS handshake fails before secureConnection event + // Expected: No connectionId assigned, no connectionClosed event, connection not tracked + + // Track connectionClosed event (should NOT fire) + let connectionClosedEmitted = false; + tlsHandshakeTestProxy.once('connectionClosed', () => { + connectionClosedEmitted = true; + }); + + const initialConnections = tlsHandshakeTestProxy.getConnectionIds(); + expect(initialConnections).to.have.lengthOf(0); + + // Attempt failed handshake (certificate validation) + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: tlsHandshakeTestProxy.port, + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.3', + rejectUnauthorized: true, // Will reject self-signed cert + }); + + // Give a small buffer for any delayed events + await wait(100); + + expect(result.success).to.be.false; + expect(connectionClosedEmitted).to.be.false; + + const finalConnections = tlsHandshakeTestProxy.getConnectionIds(); + expect(finalConnections).to.have.lengthOf(0); + }); + + it('tracks successful TLS handshakes in connection stats (contrast with failures)', async () => { + // Scenario: Successful TLS handshake with compatible protocol version + // Expected: Connection tracked with stats, connectionClosed event emitted + + const statsPromise = new Promise((resolve) => { + tlsHandshakeTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + // Attempt SUCCESSFUL handshake (TLS 1.2 - compatible with server) + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: tlsHandshakeTestProxy.port, + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.2', + rejectUnauthorized: false, + }); + + const connectionClosedStats = await statsPromise; + + expect(result.success).to.be.true; + expect(result.protocol).to.equal('TLSv1.2'); + + // Verify connectionClosed WAS emitted with stats + expect(connectionClosedStats).to.not.be.null; + expect(connectionClosedStats).to.deep.include(EXPECTED_TLS_HANDSHAKE_ONLY_STATS); + }); + + it('handles multiple failed handshakes without polluting connection tracking', async () => { + // Scenario: Multiple failed handshakes in sequence + // Expected: No connections tracked, no connectionClosed events, each failure handled independently + + const closedConnections = []; + tlsHandshakeTestProxy.on('connectionClosed', (data) => { + closedConnections.push(data); + }); + + // Attempt 5 failed handshakes (certificate validation) + for (let i = 0; i < 5; i++) { + await testTLSHandshake({ + host: '127.0.0.1', + port: tlsHandshakeTestProxy.port, + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.3', + rejectUnauthorized: true, // Will reject self-signed cert + }); + } + + await wait(100); // Small buffer for any delayed processing + + expect(closedConnections).to.have.lengthOf(0); + expect(tlsHandshakeTestProxy.getConnectionIds()).to.have.lengthOf(0); + + tlsHandshakeTestProxy.removeAllListeners('connectionClosed'); + }); + + it('correctly isolates failed and successful handshakes', async () => { + // Scenario: Mixed sequence of successful and failed handshakes + // Expected: Only successful connections tracked with stats, failures properly isolated + + const closedConnections = []; + tlsHandshakeTestProxy.on('connectionClosed', (data) => closedConnections.push(data)); + + // Pattern: fail, succeed, fail, succeed, fail + // Failures use certificate validation, successes accept cert + const scenarios = [ + { rejectCert: true, shouldFail: true }, + { rejectCert: false, shouldFail: false }, + { rejectCert: true, shouldFail: true }, + { rejectCert: false, shouldFail: false }, + { rejectCert: true, shouldFail: true }, + ]; + + for (const { rejectCert, shouldFail } of scenarios) { + const closurePromise = shouldFail ? null : new Promise((resolve) => { + tlsHandshakeTestProxy.once('connectionClosed', resolve); + }); + + const result = await testTLSHandshake({ + host: '127.0.0.1', + port: tlsHandshakeTestProxy.port, + minVersion: 'TLSv1.2', + maxVersion: 'TLSv1.3', + rejectUnauthorized: rejectCert, // Fail when rejecting self-signed cert + }); + + expect(result.success).to.equal(!shouldFail); + + if (closurePromise) { + await closurePromise; // Wait for successful connection to close + } else { + await wait(100); // Small buffer for failed connection + } + } + + expect(closedConnections).to.have.lengthOf(2); + + const connectionIds = closedConnections.map(c => c.connectionId); + const uniqueIds = [...new Set(connectionIds)]; + expect(uniqueIds).to.have.lengthOf(2); + + // Note: Byte counts vary based on TLS version (1.2 vs 1.3) and session resumption + closedConnections.forEach((conn) => { + expect(conn.stats).to.exist; + expect(conn.stats.srcTxBytes).to.be.a('number').greaterThan(1000).lessThan(3000); + expect(conn.stats.srcRxBytes).to.be.a('number').greaterThan(200).lessThan(500); + expect(conn.stats.trgTxBytes).to.be.null; + expect(conn.stats.trgRxBytes).to.be.null; + }); + + tlsHandshakeTestProxy.removeAllListeners('connectionClosed'); + }); + }); + + describe('Monitoring connection lifecycle and statistics evolution', () => { + let lifecycleTestProxy; + + beforeEach(async () => { + lifecycleTestProxy = new Server({ + port: freePorts.shift(), + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await lifecycleTestProxy.listen(); + }); + + afterEach(async () => { + if (lifecycleTestProxy) { + await lifecycleTestProxy.close(true); + } + await wait(200); + }); + + it('stats increase monotonically throughout connection lifecycle', async function() { + // Increase timeout for this test (chunked response with delays) + this.timeout(12000); + + // Create chunked target server with 2 chunks + const chunk1 = 'A'.repeat(10000); + const chunk2 = 'B'.repeat(10000); + + const chunkedPort = freePorts.shift(); + const chunkedServer = new ChunkedTargetServer({ + port: chunkedPort, + chunks: [chunk1, chunk2], + delayBetweenChunks: 1500, + }); + + await chunkedServer.listen(); + + try { + const targetUrl = `http://127.0.0.1:${chunkedPort}/`; + + let connectionId = null; + let closedStats = null; + const snapshots = []; + + lifecycleTestProxy.once('connectionClosed', ({ stats }) => { + closedStats = stats; + }); + + const agent = createNonCachingAgent(); + + const requestPromise = requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${lifecycleTestProxy.port}`, + agent, + }); + + // Wait for TLS handshake to complete and connection to be registered + await wait(400); + + const connectionIds = lifecycleTestProxy.getConnectionIds(); + expect(connectionIds).to.have.lengthOf(1); + connectionId = connectionIds[0]; + + // Snapshot 1: Early in connection (after handshake, early in or before transfer) + const snapshot1 = captureStatsSnapshot(lifecycleTestProxy, connectionId, 'early_in_connection'); + expect(snapshot1).to.not.be.null; + snapshots.push(snapshot1); + + // Wait longer to ensure some data has been transferred + await wait(2000); // Wait for chunk 1 + delay + start of chunk 2 + + // Snapshot 2: Mid-connection (during transfer, if connection still active) + const snapshot2 = captureStatsSnapshot(lifecycleTestProxy, connectionId, 'mid_connection'); + + if (snapshot2 !== null) { + snapshots.push(snapshot2); + } + + // Wait for request to complete + await requestPromise; + + // Wait for connectionClosed event + await wait(500); + + // Snapshot 3: From connectionClosed event (final) + expect(closedStats).to.exist; + snapshots.push({ + label: 'connection_closed', + timestamp: Date.now(), + srcTxBytes: closedStats.srcTxBytes, + srcRxBytes: closedStats.srcRxBytes, + trgTxBytes: closedStats.trgTxBytes, + trgRxBytes: closedStats.trgRxBytes, + }); + + // Validate monotonic increase across all captured snapshots + for (let i = 1; i < snapshots.length; i++) { + const prev = snapshots[i - 1]; + const curr = snapshots[i]; + + expect(curr.srcTxBytes, `srcTxBytes decreased from ${prev.label} to ${curr.label}`) + .to.be.at.least(prev.srcTxBytes); + expect(curr.srcRxBytes, `srcRxBytes decreased from ${prev.label} to ${curr.label}`) + .to.be.at.least(prev.srcRxBytes); + + // Target bytes should also be monotonic (if not null) + if (curr.trgTxBytes !== null && prev.trgTxBytes !== null) { + expect(curr.trgTxBytes, `trgTxBytes decreased from ${prev.label} to ${curr.label}`) + .to.be.at.least(prev.trgTxBytes); + } + if (curr.trgRxBytes !== null && prev.trgRxBytes !== null) { + expect(curr.trgRxBytes, `trgRxBytes decreased from ${prev.label} to ${curr.label}`) + .to.be.at.least(prev.trgRxBytes); + } + } + + const firstSnapshot = snapshots[0]; + const lastSnapshot = snapshots[snapshots.length - 1]; + + expect(closedStats).to.deep.include(EXPECTED_CHUNKED_20KB_STATS); + + const totalTxIncrease = lastSnapshot.srcTxBytes - firstSnapshot.srcTxBytes; + expect(totalTxIncrease).to.be.greaterThan(5000); // At least 5KB transferred + + const rxVariance = Math.abs(lastSnapshot.srcRxBytes - firstSnapshot.srcRxBytes); + expect(rxVariance).to.be.lessThan(500); // RX should stay relatively stable + } finally { + await chunkedServer.close(); + freePorts.push(chunkedPort); + } + }); + + it('getConnectionStats() returns undefined after connection closes', async () => { + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + const closurePromise = new Promise((resolve) => { + lifecycleTestProxy.once('connectionClosed', ({ stats, connectionId }) => { + resolve({ stats, connectionId }); + }); + }); + + const agent = createNonCachingAgent(); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${lifecycleTestProxy.port}`, + agent, + }); + + const { stats: closedStats, connectionId } = await closurePromise; + + expect(connectionId).to.be.a('number'); + expect(closedStats).to.deep.include(EXPECTED_HTTPS_STATS); + + const statsAfterClose = lifecycleTestProxy.getConnectionStats(connectionId); + expect(statsAfterClose).to.be.undefined; + + const activeConnectionIds = lifecycleTestProxy.getConnectionIds(); + expect(activeConnectionIds).to.be.an('array').that.is.empty; + }); + + it('getConnectionStats() matches connectionClosed event stats', async function() { + this.timeout(8000); + + // Use chunked server with delay to keep connection open longer + const chunk1 = 'X'.repeat(3000); // 3KB + const chunk2 = 'Y'.repeat(3000); // 3KB + const chunkedPort = freePorts.shift(); + const chunkedServer = new ChunkedTargetServer({ + port: chunkedPort, + chunks: [chunk1, chunk2], + delayBetweenChunks: 2000, // 2 second delay keeps connection open + }); + + await chunkedServer.listen(); + + try { + const targetUrl = `http://127.0.0.1:${chunkedPort}/`; + + let connectionId = null; + let statsBeforeClose = null; + + const closurePromise = new Promise((resolve) => { + lifecycleTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + const agent = createNonCachingAgent(); + + const requestPromise = requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${lifecycleTestProxy.port}`, + agent, + }); + + await wait(500); + + const connectionIds = lifecycleTestProxy.getConnectionIds(); + expect(connectionIds).to.have.lengthOf(1); + connectionId = connectionIds[0]; + + statsBeforeClose = lifecycleTestProxy.getConnectionStats(connectionId); + expect(statsBeforeClose).to.exist; + + await requestPromise; + + const closedStats = await closurePromise; + + expect(closedStats).to.exist; + expect(closedStats).to.deep.include(EXPECTED_CHUNKED_6KB_STATS); + + // Stats before close should match or be close to connectionClosed event stats + // Stats captured mid-transfer may be slightly less than final stats + expect(closedStats.srcTxBytes).to.be.at.least(statsBeforeClose.srcTxBytes); + expect(closedStats.srcRxBytes).to.be.at.least(statsBeforeClose.srcRxBytes); + expect(closedStats.trgTxBytes).to.be.at.least(statsBeforeClose.trgTxBytes); + expect(closedStats.trgRxBytes).to.be.at.least(statsBeforeClose.trgRxBytes); + + // Difference should be small (data in flight or final flush) + const txDiff = closedStats.srcTxBytes - statsBeforeClose.srcTxBytes; + const rxDiff = closedStats.srcRxBytes - statsBeforeClose.srcRxBytes; + + expect(txDiff).to.be.lessThan(10000); // Allow for in-flight data + expect(rxDiff).to.be.lessThan(1000); // Request already sent + } finally { + await chunkedServer.close(); + freePorts.push(chunkedPort); + } + }); + + it('stats accurately reflect known request/response sizes', async function() { + // Increase timeout for this test (chunked response with delays) + this.timeout(10000); + + // Create chunked target server with known response size + // 3 chunks of 10KB each = 30KB total response + const chunk1 = 'X'.repeat(10000); + const chunk2 = 'Y'.repeat(10000); + const chunk3 = 'Z'.repeat(10000); + + const chunkedPort = freePorts.shift(); + const chunkedServer = new ChunkedTargetServer({ + port: chunkedPort, + chunks: [chunk1, chunk2, chunk3], + delayBetweenChunks: 1000, + }); + + await chunkedServer.listen(); + + try { + const targetUrl = `http://127.0.0.1:${chunkedPort}/`; + + const closurePromise = new Promise((resolve) => { + lifecycleTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + const agent = createNonCachingAgent(); + + const { body } = await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${lifecycleTestProxy.port}`, + agent, + }); + + expect(body).to.equal(chunk1 + chunk2 + chunk3); + + const closedStats = await closurePromise; + + expect(closedStats).to.exist; + expect(closedStats).to.deep.include(EXPECTED_CHUNKED_30KB_STATS); + + // Stats accurately reflect known sizes: + // - Target: 30KB (30000 bytes) + 124 bytes headers = 30124 bytes + // - Source: 30124 bytes + TLS overhead (handshake ~2.5KB + encryption ~2KB) = 32249 bytes + // - TLS overhead percentage: (32249 - 30124) / 30124 = ~7% (typical for large responses) + } finally { + await chunkedServer.close(); + freePorts.push(chunkedPort); + } + }); + + it('handles concurrent getConnectionStats() calls accessing _parent safely', async function() { + // Increase timeout for this test (chunked response with delays) + this.timeout(10000); + + // Create chunked target server with delays to keep connection alive + const chunk1 = Buffer.alloc(10000); + const chunk2 = Buffer.alloc(10000); + + const chunkedPort = freePorts.shift(); + const chunkedServer = new ChunkedTargetServer({ + port: chunkedPort, + chunks: [chunk1, chunk2], + delayBetweenChunks: 2000, + }); + + await chunkedServer.listen(); + + try { + const targetUrl = `http://127.0.0.1:${chunkedPort}/`; + + let connectionId = null; + + // Capture final stats when connection closes + const closurePromise = new Promise((resolve) => { + lifecycleTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + // Start request (don't await - let it run in background) + const agent = createNonCachingAgent(); + + const requestPromise = requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${lifecycleTestProxy.port}`, + agent, + }); + + await wait(500); + + const connectionIds = lifecycleTestProxy.getConnectionIds(); + expect(connectionIds).to.have.lengthOf(1); + connectionId = connectionIds[0]; + + // Query stats 100 times concurrently + const statQueries = Array.from({ length: 100 }, () => + lifecycleTestProxy.getConnectionStats(connectionId) + ); + + const results = await Promise.all(statQueries); + + // All queries successful (no undefined) + results.forEach((stats, i) => { + expect(stats, `Query ${i}`).to.not.be.undefined; + expect(stats).to.be.an('object'); + }); + + // Validate exact values for stable metrics and ranges for in-progress metrics + results.forEach((stats, i) => { + // Source RX: Request fully sent to proxy (stable) + expect(stats.srcRxBytes, `Query ${i} srcRxBytes`) + .to.equal(517); // Exact value - request complete + + // Target TX: Request fully forwarded to target (stable) + expect(stats.trgTxBytes, `Query ${i} trgTxBytes`) + .to.equal(60); // Exact value - request forwarded + + // Source TX: Handshake complete, partial response in progress + expect(stats.srcTxBytes, `Query ${i} srcTxBytes`) + .to.be.a('number') + .greaterThan(2000) // At least TLS handshake (~2.5KB) + request + .lessThan(15000); // Less than full response (22227) + + // Target RX: First chunk in progress (0 to 10KB) + expect(stats.trgRxBytes, `Query ${i} trgRxBytes`) + .to.be.a('number') + .greaterThan(-1) // Can be 0 if target hasn't started sending + .lessThan(11000); // Less than first full chunk (10KB) + headers + }); + + // Values are consistent across all queries + // All srcTxBytes values should be very similar (within 1KB variance) + const txValues = results.map(s => s.srcTxBytes); + const rxValues = results.map(s => s.srcRxBytes); + + const minTx = Math.min(...txValues); + const maxTx = Math.max(...txValues); + const minRx = Math.min(...rxValues); + const maxRx = Math.max(...rxValues); + + // Within 1KB variance is acceptable (data might be in-flight) + expect(maxTx - minTx, 'TX variance should be < 1KB').to.be.lessThan(1000); + expect(maxRx - minRx, 'RX variance should be < 1KB').to.be.lessThan(1000); + + await requestPromise; + + const closedStats = await closurePromise; + + expect(closedStats).to.exist; + expect(closedStats).to.deep.include(EXPECTED_CHUNKED_20KB_STATS); + + freePorts.push(chunkedPort); + } finally { + await chunkedServer.close(); + } + }); + }); + + describe('Multiple Requests and TLS Overhead Amortization', () => { + // Validates TLS overhead behavior across keep-alive vs separate connections + // Critical for production billing accuracy + + let keepAliveTestProxy; + + beforeEach(async () => { + const testPort = freePorts.shift(); + keepAliveTestProxy = new Server({ + port: testPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await keepAliveTestProxy.listen(); + }); + + afterEach(async () => { + if (keepAliveTestProxy) { + await keepAliveTestProxy.close(true); + } + await wait(200); // Let connections fully close + }); + + it('keep-alive connection amortizes TLS handshake overhead across multiple requests', async function() { + // Scenario: 10 requests over a single keep-alive connection + // Expected: ONE TLS handshake (~2.5KB) + 100KB data + ~5% encryption overhead + // Total: ~107KB (handshake cost amortized across all requests) + + this.timeout(15000); // 10 requests with processing time + + // Create chunked server with 10KB responses + const chunk1 = 'A'.repeat(10000); + const chunkedPort = freePorts.shift(); + const chunkedServer = new ChunkedTargetServer({ + port: chunkedPort, + chunks: [chunk1], + delayBetweenChunks: 0, + }); + await chunkedServer.listen(); + + try { + const targetUrl = `http://127.0.0.1:${chunkedPort}/`; + + const statsPromise = new Promise((resolve) => { + keepAliveTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + // Create agent with keep-alive enabled + const agent = createNonCachingAgent({ + keepAlive: true, // Enable keep-alive (connection reuse) + maxSockets: 1, // Limit to 1 socket to ensure reuse + }); + + // Make 10 requests over SAME connection + for (let i = 0; i < 10; i++) { + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${keepAliveTestProxy.port}`, + agent, // Reuse same agent + strictSSL: false, + rejectUnauthorized: false, + // NO Connection: close header - keep-alive is default + }); + } + + // Close agent to trigger connection closure + agent.destroy(); + + const keepAliveStats = await statsPromise; + + expect(keepAliveStats).to.deep.include(EXPECTED_KEEPALIVE_10REQ_STATS); + + // Calculate total TLS overhead (handshake + encryption on both paths) + // Formula: (source bytes with TLS) - (target bytes without TLS) + // = (srcTxBytes + srcRxBytes) - (trgTxBytes + trgRxBytes) + // This measures complete TLS overhead including: + // - TLS handshake (~3.5KB for TLS 1.3 with this request size) + // - Encryption overhead on requests (~18% of total overhead) + // - Encryption overhead on responses (~82% of total overhead) + const totalOverhead = (keepAliveStats.srcTxBytes + keepAliveStats.srcRxBytes) + - (keepAliveStats.trgTxBytes + keepAliveStats.trgRxBytes); + + // Expected overhead: (103799 + 1503) - (600 + 101240) = 3462 bytes + const expectedOverhead = (EXPECTED_KEEPALIVE_10REQ_STATS.srcTxBytes + EXPECTED_KEEPALIVE_10REQ_STATS.srcRxBytes) + - (EXPECTED_KEEPALIVE_10REQ_STATS.trgTxBytes + EXPECTED_KEEPALIVE_10REQ_STATS.trgRxBytes); + + expect(totalOverhead).to.equal(expectedOverhead); + + freePorts.push(chunkedPort); + } finally { + await chunkedServer.close(); + } + }); + + it('separate connections have higher TLS overhead than keep-alive', async function() { + // Scenario: 10 requests over 10 separate connections (Connection: close) + // Expected: 10 TLS handshakes (~25KB) + 100KB data + ~5% encryption overhead + // Total: ~130KB (handshake cost NOT amortized) + + this.timeout(20000); // 10 separate connections take longer + + // Create chunked server with 10KB responses + const chunk1 = 'B'.repeat(10000); + const chunkedPort = freePorts.shift(); + const chunkedServer = new ChunkedTargetServer({ + port: chunkedPort, + chunks: [chunk1], + delayBetweenChunks: 0, + }); + await chunkedServer.listen(); + + try { + const targetUrl = `http://127.0.0.1:${chunkedPort}/`; + + // Collect all stats from multiple connections + const allStats = []; + const statsListener = ({ stats }) => { + allStats.push(stats); + }; + keepAliveTestProxy.on('connectionClosed', statsListener); + + // Make 10 requests, each with NEW connection + for (let i = 0; i < 10; i++) { + // Create NEW agent for each request (prevents connection reuse) + const agent = createNonCachingAgent(); + + const closurePromise = new Promise((resolve) => { + keepAliveTestProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${keepAliveTestProxy.port}`, + agent, // NEW agent per request + strictSSL: false, + rejectUnauthorized: false, + headers: { + 'Connection': 'close', // Force connection closure + }, + }); + + await closurePromise; // Wait for connection to close before next request + } + + expect(allStats.length).to.equal(10, 'Should have exactly 10 connections'); + + // Validate exact total byte counts across all 10 connections + const totals = { + srcTxBytes: allStats.reduce((sum, s) => sum + s.srcTxBytes, 0), + srcRxBytes: allStats.reduce((sum, s) => sum + s.srcRxBytes, 0), + trgTxBytes: allStats.reduce((sum, s) => sum + s.trgTxBytes, 0), + trgRxBytes: allStats.reduce((sum, s) => sum + s.trgRxBytes, 0), + }; + expect(totals).to.deep.include(EXPECTED_SEPARATE_10REQ_TOTAL); + + // Calculate total TLS overhead across all connections + // Formula: (all source bytes with TLS) - (all target bytes without TLS) + const totalOverhead = (totals.srcTxBytes + totals.srcRxBytes) + - (totals.trgTxBytes + totals.trgRxBytes); + + // Expected overhead: (122050 + 5170) - (600 + 101240) = 25380 bytes + const expectedOverhead = (EXPECTED_SEPARATE_10REQ_TOTAL.srcTxBytes + EXPECTED_SEPARATE_10REQ_TOTAL.srcRxBytes) + - (EXPECTED_SEPARATE_10REQ_TOTAL.trgTxBytes + EXPECTED_SEPARATE_10REQ_TOTAL.trgRxBytes); + expect(totalOverhead).to.equal(expectedOverhead); + + keepAliveTestProxy.removeListener('connectionClosed', statsListener); + freePorts.push(chunkedPort); + } finally { + await chunkedServer.close(); + } + }); + + it('validates overhead ratio: separate connections have ~10x more handshake overhead', async function() { + // Comparative test: Validates the KEY billing behavior + // Keep-alive should have ~10 LESS handshake overhead than separate connections + // This is critical for accurate bandwidth accounting in production + + this.timeout(25000); // Both scenarios run sequentially + + // Create chunked server with 10KB responses + const chunk1 = 'C'.repeat(10000); + const chunkedPort = freePorts.shift(); + const chunkedServer = new ChunkedTargetServer({ + port: chunkedPort, + chunks: [chunk1], + delayBetweenChunks: 0, + }); + await chunkedServer.listen(); + + try { + const targetUrl = `http://127.0.0.1:${chunkedPort}/`; + + // Scenario A: Keep-alive (1 connection, 10 requests) + const keepAliveStatsPromise = new Promise((resolve) => { + keepAliveTestProxy.once('connectionClosed', ({ stats }) => resolve(stats)); + }); + + const keepAliveAgent = createNonCachingAgent({ + keepAlive: true, + maxSockets: 1, + }); + + for (let i = 0; i < 10; i++) { + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${keepAliveTestProxy.port}`, + agent: keepAliveAgent, + strictSSL: false, + rejectUnauthorized: false, + }); + } + + keepAliveAgent.destroy(); + const keepAliveStats = await keepAliveStatsPromise; + + // Scenario B: Separate connections (10 connections, 10 requests) + const separateStats = []; + const statsListener = ({ stats }) => { + separateStats.push(stats); + }; + keepAliveTestProxy.on('connectionClosed', statsListener); + + for (let i = 0; i < 10; i++) { + const agent = createNonCachingAgent(); + + const closurePromise = new Promise((resolve) => { + keepAliveTestProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${keepAliveTestProxy.port}`, + agent, + strictSSL: false, + rejectUnauthorized: false, + headers: { + 'Connection': 'close', + }, + }); + + await closurePromise; + } + + expect(separateStats.length).to.equal(10); + + // Validate both scenarios match expected values + expect(keepAliveStats).to.deep.include(EXPECTED_KEEPALIVE_10REQ_STATS); + + const separateTotals = { + srcTxBytes: separateStats.reduce((sum, s) => sum + s.srcTxBytes, 0), + srcRxBytes: separateStats.reduce((sum, s) => sum + s.srcRxBytes, 0), + trgTxBytes: separateStats.reduce((sum, s) => sum + s.trgTxBytes, 0), + trgRxBytes: separateStats.reduce((sum, s) => sum + s.trgRxBytes, 0), + }; + expect(separateTotals).to.deep.include(EXPECTED_SEPARATE_10REQ_TOTAL); + + // Calculate total TLS overhead for both scenarios + // Keep-alive: (source bytes with TLS) - (target bytes without TLS) + const keepAliveOverhead = (keepAliveStats.srcTxBytes + keepAliveStats.srcRxBytes) + - (keepAliveStats.trgTxBytes + keepAliveStats.trgRxBytes); + + // Separate: sum across all 10 connections + const separateOverhead = (separateTotals.srcTxBytes + separateTotals.srcRxBytes) + - (separateTotals.trgTxBytes + separateTotals.trgRxBytes); + + // Expected values from constants + // Keep-alive: (103799 + 1503) - (600 + 101240) = 3462 bytes + // Separate: (122050 + 5170) - (600 + 101240) = 25380 bytes + const expectedKeepAliveOverhead = (EXPECTED_KEEPALIVE_10REQ_STATS.srcTxBytes + EXPECTED_KEEPALIVE_10REQ_STATS.srcRxBytes) + - (EXPECTED_KEEPALIVE_10REQ_STATS.trgTxBytes + EXPECTED_KEEPALIVE_10REQ_STATS.trgRxBytes); + const expectedSeparateOverhead = (EXPECTED_SEPARATE_10REQ_TOTAL.srcTxBytes + EXPECTED_SEPARATE_10REQ_TOTAL.srcRxBytes) + - (EXPECTED_SEPARATE_10REQ_TOTAL.trgTxBytes + EXPECTED_SEPARATE_10REQ_TOTAL.trgRxBytes); + + // Validate exact overhead values + expect(keepAliveOverhead).to.equal(expectedKeepAliveOverhead); // 3462 bytes + expect(separateOverhead).to.equal(expectedSeparateOverhead); // 25380 bytes + + // Validate overhead ratio + // Expected ratio: 25380 / 3462 = 7.33 + const overheadRatio = separateOverhead / keepAliveOverhead; + const expectedRatio = expectedSeparateOverhead / expectedKeepAliveOverhead; + expect(overheadRatio).to.equal(expectedRatio); + + keepAliveTestProxy.removeListener('connectionClosed', statsListener); + freePorts.push(chunkedPort); + } finally { + await chunkedServer.close(); + } + }); + + it('tracks TLS overhead correctly for resumed sessions (TLS 1.3)', async function() { + // Scenario: 3 sequential requests with session caching enabled + // Expected: + // - First request has full TLS 1.3 handshake overhead (~2255 bytes srcTxBytes) + // - Second and third requests have resumed session overhead (~445 bytes srcTxBytes, ~80% reduction) + // Testing 2 resumed sessions (not just 1) proves session resumption is repeatable and stable + // + // Note: TLS 1.3 tested explicitly. TLS 1.2 session resumption uses the same + // _parent socket tracking mechanism and is implicitly validated. TLS 1.2 + // produces different exact byte counts (~2500-3000 full handshake, ~500-800 resumed) + // but achieves similar overhead reduction (~70-80%). The implementation is protocol-agnostic. + + this.timeout(10000); + + const targetUrl = `http://127.0.0.1:${targetServer.httpServer.address().port}/hello-world`; + + const allStats = []; + const statsListener = ({ stats }) => { + allStats.push(stats); + }; + keepAliveTestProxy.on('connectionClosed', statsListener); + + // Create agent with session caching ENABLED and force TLS 1.3 + const agent = new https.Agent({ + maxCachedSessions: 1, // Enable session caching (opposite of other tests!) + minVersion: 'TLSv1.3', // Force TLS 1.3 for deterministic byte counts + maxVersion: 'TLSv1.3', + }); + + // Request 1: Full handshake + const closurePromise1 = new Promise((resolve) => { + keepAliveTestProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${keepAliveTestProxy.port}`, + agent, + strictSSL: false, + rejectUnauthorized: false, + headers: { 'Connection': 'close' }, // Force connection closure to trigger stats + }); + + await closurePromise1; + + // Request 2: Resumed session (reuses TLS session from Request 1) + const closurePromise2 = new Promise((resolve) => { + keepAliveTestProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${keepAliveTestProxy.port}`, + agent, // SAME agent = session cache hit + strictSSL: false, + rejectUnauthorized: false, + headers: { 'Connection': 'close' }, + }); + + await closurePromise2; + + // Request 3: Resumed session (reuses TLS session from Request 1) + const closurePromise3 = new Promise((resolve) => { + keepAliveTestProxy.once('connectionClosed', resolve); + }); + + await requestPromised({ + url: targetUrl, + proxy: `https://127.0.0.1:${keepAliveTestProxy.port}`, + agent, // SAME agent = session cache hit + strictSSL: false, + rejectUnauthorized: false, + headers: { 'Connection': 'close' }, + }); + + await closurePromise3; + + expect(allStats.length).to.equal(3); + + const [fullHandshakeStats, resumedSessionStats1, resumedSessionStats2] = allStats; + + // Full handshake: TLS 1.3 full handshake overhead + expect(fullHandshakeStats.srcTxBytes).to.equal(2255); + expect(fullHandshakeStats.srcRxBytes).to.equal(404); + expect(fullHandshakeStats.trgTxBytes).to.equal(71); + expect(fullHandshakeStats.trgRxBytes).to.equal(174); + + // Resumed session: TLS 1.3 session resumption (80.3% reduction in srcTxBytes) + expect(resumedSessionStats1.srcTxBytes).to.equal(445); + expect(resumedSessionStats1.srcRxBytes).to.equal(675); + expect(resumedSessionStats1.trgTxBytes).to.equal(71); + expect(resumedSessionStats1.trgRxBytes).to.equal(174); + + expect(resumedSessionStats2.srcTxBytes).to.equal(445); + expect(resumedSessionStats2.srcRxBytes).to.equal(675); + expect(resumedSessionStats2.trgTxBytes).to.equal(71); + expect(resumedSessionStats2.trgRxBytes).to.equal(174); + + + // Validate overhead reduction + // TLS 1.3 session resumption reduces client -> proxy handshake bytes by ~80% + // Note: srcRxBytes is higher for resumed session due to TLS 1.3 NewSessionTicket message + const overheadReduction1 = (fullHandshakeStats.srcTxBytes - resumedSessionStats1.srcTxBytes) / fullHandshakeStats.srcTxBytes; + expect(overheadReduction1).to.be.approximately(0.803, 0.01); // 80.3% reduction + + const overheadReduction2 = (fullHandshakeStats.srcTxBytes - resumedSessionStats2.srcTxBytes) / fullHandshakeStats.srcTxBytes; + expect(overheadReduction2).to.be.approximately(0.803, 0.01); // 80.3% reduction + + keepAliveTestProxy.removeListener('connectionClosed', statsListener); + agent.destroy(); + }); + }); +}); + +describe('WebSocket TLS Overhead Tracking', function () { + this.timeout(30000); + + it('websocket connection through HTTP proxy without TLS overhead for single message', async () => { + const [targetServerPort, httpProxyPort] = await portastic.find({ min: 49000, max: 50000, retrieve: 2 }); + + const targetServer = new TargetServer({ port: targetServerPort, useSsl: false }); + await targetServer.listen(); + + const httpProxyServer = new Server({ + port: httpProxyPort, + serverType: 'http', + verbose: false, + }); + await httpProxyServer.listen(); + + try { + const statsPromise = awaitConnectionStats(httpProxyServer); + + // Manual CONNECT tunneling for accurate byte counting + const response = await new Promise((resolve, reject) => { + const targetHostPort = `127.0.0.1:${targetServerPort}`; + + const connectRequest = http.request({ + host: '127.0.0.1', + port: httpProxyPort, + method: 'CONNECT', + path: targetHostPort, + headers: { 'Host': targetHostPort }, + }); + + connectRequest.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`CONNECT failed: ${res.statusCode}`)); + return; + } + + const ws = new WebSocket(`ws://${targetHostPort}`, { + createConnection: () => socket, + }); + + ws.on('error', (err) => { + ws.close(); + reject(err); + }); + + ws.on('open', () => ws.send('hello world')); + + ws.on('message', (data) => { + ws.close(); + resolve(data.toString()); + }); + }); + + connectRequest.on('error', reject); + connectRequest.end(); + }); + + expect(response).to.equal('I received: hello world'); + + const stats = await statsPromise; + + const EXPECTED_WS_STATS = { + srcTxBytes: 195, + srcRxBytes: 325, + trgTxBytes: 247, + trgRxBytes: 156, + }; + + expect(stats).to.deep.include(EXPECTED_WS_STATS); + } finally { + await targetServer.close(); + await httpProxyServer.close(); + } + }); + + it('websocket connection through HTTPS proxy tracks TLS overhead correctly for single message', async () => { + const [targetServerPort, httpsProxyPort] = await portastic.find({ min: 49000, max: 50000, retrieve: 2 }); + + const targetServer = new TargetServer({ port: targetServerPort, useSsl: false }); + await targetServer.listen(); + + const httpsProxyServer = new Server({ + port: httpsProxyPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await httpsProxyServer.listen(); + + try { + const statsPromise = awaitConnectionStats(httpsProxyServer); + + // Manual CONNECT tunneling for accurate byte counting + const response = await new Promise((resolve, reject) => { + const targetHostPort = `127.0.0.1:${targetServerPort}`; + + const connectRequest = https.request({ + host: '127.0.0.1', + port: httpsProxyPort, + method: 'CONNECT', + path: targetHostPort, + headers: { 'Host': targetHostPort }, + rejectUnauthorized: false, + }); + + connectRequest.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`CONNECT failed: ${res.statusCode}`)); + return; + } + + const ws = new WebSocket(`ws://${targetHostPort}`, { + createConnection: () => socket, + }); + + ws.on('error', (err) => { + ws.close(); + reject(err); + }); + + ws.on('open', () => ws.send('hello world')); + + ws.on('message', (data) => { + ws.close(); + resolve(data.toString()); + }); + }); + + connectRequest.on('error', reject); + connectRequest.end(); + }); + + expect(response).to.equal('I received: hello world'); + + const stats = await statsPromise; + + const EXPECTED_WS_STATS = { + srcTxBytes: 2342, + srcRxBytes: 850, + trgTxBytes: 247, + trgRxBytes: 156, + }; + + expect(stats).to.deep.include(EXPECTED_WS_STATS); + } finally { + await targetServer.close(); + await httpsProxyServer.close(); + } + }); + + it('websocket connection through HTTPS proxy tracks TLS overhead correctly for multiple message', async () => { + const [targetServerPort, httpsProxyPort] = await portastic.find({ min: 49000, max: 50000, retrieve: 2 }); + + const targetServer = new TargetServer({ port: targetServerPort, useSsl: false }); + await targetServer.listen(); + + const httpsProxyServer = new Server({ + port: httpsProxyPort, + serverType: 'https', + httpsOptions: { key: sslKey, cert: sslCrt }, + verbose: false, + }); + await httpsProxyServer.listen(); + + try { + let connectionId = null; + const statsSnapshots = []; + + // Capture connection ID when connection opens + httpsProxyServer.once('connectionClosed', ({ connectionId: id, stats }) => { + connectionId = id; + statsSnapshots.push({ label: 'final', ...stats }); + }); + + // Create WebSocket connection and send multiple messages + const targetHost = '127.0.0.1'; + const targetHostPort = `${targetHost}:${targetServerPort}`; + const wsUrl = `ws://${targetHostPort}`; + const proxyUrl = `https://127.0.0.1:${httpsProxyPort}`; + const messagesToSend = 5; + + await new Promise((resolve, reject) => { + const proxyParsed = new URL(proxyUrl); + + // Create CONNECT request to proxy + const connectRequest = https.request({ + host: proxyParsed.hostname, + port: proxyParsed.port, + method: 'CONNECT', + path: targetHostPort, + headers: { 'Host': targetHostPort }, + rejectUnauthorized: false, + }); + + connectRequest.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`Proxy CONNECT failed: ${res.statusCode}`)); + return; + } + + const ws = new WebSocket(wsUrl, { + createConnection: () => socket, + }); + + let messagesSent = 0; + let messagesReceived = 0; + + ws.on('error', (err) => { + ws.close(); + reject(err); + }); + + ws.on('open', () => { + // Send first message + ws.send(`message-${messagesSent++}`); + }); + + ws.on('message', (data) => { + messagesReceived++; + expect(data.toString()).to.match(/^I received: message-\d+$/); + + if (messagesSent < messagesToSend) { + // Send next message + ws.send(`message-${messagesSent++}`); + } else if (messagesReceived === messagesToSend) { + // All messages sent and received + ws.close(); + resolve(); + } + }); + }); + + connectRequest.on('error', reject); + connectRequest.end(); + }); + + // Wait for connection to close + await wait(100); + + // Verify we captured stats + expect(statsSnapshots.length).to.equal(1); + const finalStats = statsSnapshots[0]; + + const EXPECTED_MULTI_MSG_STATS = { + srcTxBytes: 2520, + srcRxBytes: 1267, + trgTxBytes: 305, + trgRxBytes: 246, + }; + + const { label, ...statsWithoutLabel } = finalStats; + expect(statsWithoutLabel).to.deep.equal(EXPECTED_MULTI_MSG_STATS); + } finally { + await targetServer.close(); + await httpsProxyServer.close(); + } + }); +}); + +describe('TLS Overhead with SOCKS5 Upstream', function () { + this.timeout(20000); + + it('GET request via SOCKS5 without authentication and without TLS overhead', async () => { + const [socksPort, httpProxyPort, targetPort] = await portastic.find({ min: 51000, max: 51500, retrieve: 3 }); + + const socksServer = socksv5.createServer((_, accept) => { + accept(); + }); + + await new Promise((resolve) => { + socksServer.listen(socksPort, '127.0.0.1', () => { + socksServer.useAuth(socksv5.auth.None()); + resolve(); + }); + }); + + const targetServer = new TargetServer({ port: targetPort, useSsl: false }); + await targetServer.listen(); + + // Setup HTTP proxy with SOCKS5 upstream (no auth) + const httpProxyServer = new Server({ + port: httpProxyPort, + serverType: 'http', + prepareRequestFunction: () => ({ + upstreamProxyUrl: `socks5://127.0.0.1:${socksPort}`, + }), + verbose: false, + }); + await httpProxyServer.listen(); + + try { + const statsPromise = awaitConnectionStats(httpProxyServer); + + const { response, body } = await requestPromised({ + url: `http://127.0.0.1:${targetPort}`, + proxy: `http://127.0.0.1:${httpProxyPort}`, + }); + + expect(response.statusCode).to.equal(200); + expect(body).to.equal('It works!'); + + const stats = await statsPromise; + + // Validate HTTP proxy has no TLS overhead + expect(stats).to.deep.include({ srcTxBytes: 171, srcRxBytes: 82, trgTxBytes: 73, trgRxBytes: 183 }); + } finally { + await new Promise((resolve) => socksServer.close(resolve)); + await targetServer.close(); + await httpProxyServer.close(); + } + }); + + it('GET request via SOCKS5 without authentication tracks TLS overhead correctly', async () => { + const [socksPort, httpsProxyPort, targetPort] = await portastic.find({ min: 51000, max: 51500, retrieve: 3 }); + + const socksServer = socksv5.createServer((_, accept) => { + accept(); + }); + + await new Promise((resolve) => { + socksServer.listen(socksPort, '127.0.0.1', () => { + socksServer.useAuth(socksv5.auth.None()); + resolve(); + }); + }); + + const targetServer = new TargetServer({ port: targetPort, useSsl: false }); + await targetServer.listen(); + + // Setup HTTPS proxy with SOCKS5 upstream (no auth) + const httpsProxyServer = new Server({ + port: httpsProxyPort, + serverType: 'https', + httpsOptions: { + key: sslKey, + cert: sslCrt, + maxCachedSessions: 0, // Critical for determinism + }, + prepareRequestFunction: () => ({ + upstreamProxyUrl: `socks5://127.0.0.1:${socksPort}`, + }), + verbose: false, + }); + await httpsProxyServer.listen(); + + try { + const statsPromise = awaitConnectionStats(httpsProxyServer); + + // Make GET request through HTTPS proxy (which routes via SOCKS5) + const agent = createNonCachingAgent({ rejectUnauthorized: false }); + const { response, body } = await requestPromised({ + url: `http://127.0.0.1:${targetPort}`, + proxy: `https://127.0.0.1:${httpsProxyPort}`, + agent, + }); + + expect(response.statusCode).to.equal(200); + expect(body).to.equal('It works!'); + + const stats = await statsPromise; + + expect(stats).to.deep.include(EXPECTED_SOCKS5_GET_NOAUTH_STATS); + + agent.destroy(); + } finally { + await new Promise((resolve) => socksServer.close(resolve)); + await targetServer.close(); + await httpsProxyServer.close(); + } + }); + + it('CONNECT request via SOCKS5 without authentication tracks TLS overhead correctly', async () => { + const [socksPort, httpsProxyPort, targetPort] = await portastic.find({ min: 51000, max: 51500, retrieve: 3 }); + + const socksServer = socksv5.createServer((_, accept) => { + accept(); + }); + + await new Promise((resolve) => { + socksServer.listen(socksPort, '127.0.0.1', () => { + socksServer.useAuth(socksv5.auth.None()); + resolve(); + }); + }); + + const targetServer = new TargetServer({ port: targetPort, useSsl: false }); + await targetServer.listen(); + + // Setup HTTPS proxy with SOCKS5 upstream (no auth) + const httpsProxyServer = new Server({ + port: httpsProxyPort, + serverType: 'https', + httpsOptions: { + key: sslKey, + cert: sslCrt, + maxCachedSessions: 0, // Critical for determinism + }, + prepareRequestFunction: () => ({ + upstreamProxyUrl: `socks5://127.0.0.1:${socksPort}`, + }), + verbose: false, + }); + await httpsProxyServer.listen(); + + try { + const statsPromise = awaitConnectionStats(httpsProxyServer); + + // Manual CONNECT tunneling + const response = await new Promise((resolve, reject) => { + const targetHostPort = `127.0.0.1:${targetPort}`; + + const connectRequest = https.request({ + host: '127.0.0.1', + port: httpsProxyPort, + method: 'CONNECT', + path: targetHostPort, + headers: { 'Host': targetHostPort }, + rejectUnauthorized: false, + }); + + connectRequest.on('connect', (res, socket) => { + if (res.statusCode !== 200) { + socket.destroy(); + reject(new Error(`CONNECT failed: ${res.statusCode}`)); + return; + } + + // Make HTTP request through the tunnel + const requestData = `GET / HTTP/1.1\r\nHost: ${targetHostPort}\r\nConnection: close\r\n\r\n`; + socket.write(requestData); + + let responseData = ''; + socket.on('data', (chunk) => { + responseData += chunk.toString(); + }); + + socket.on('end', () => { + socket.destroy(); + resolve(responseData); + }); + + socket.on('error', reject); + }); + + connectRequest.on('error', reject); + connectRequest.end(); + }); + + expect(response).to.contain('It works!'); + + const stats = await statsPromise; + + expect(stats).to.deep.include(EXPECTED_SOCKS5_CONNECT_NOAUTH_STATS); + } finally { + await new Promise((resolve) => socksServer.close(resolve)); + await targetServer.close(); + await httpsProxyServer.close(); + } + }); + + it('GET request via SOCKS5 with authentication shows SOCKS overhead in target bytes', async () => { + const [socksPort, httpsProxyPort, targetPort] = await portastic.find({ min: 51000, max: 51500, retrieve: 3 }); + + const socksServer = socksv5.createServer((_, accept) => { + accept(); + }); + + await new Promise((resolve) => { + socksServer.listen(socksPort, '127.0.0.1', () => { + socksServer.useAuth(socksv5.auth.UserPassword((user, password, cb) => { + // Accept credentials: username 'proxy-ch@in', password 'rules!' + cb(user === 'proxy-ch@in' && password === 'rules!'); + })); + resolve(); + }); + }); + + const targetServer = new TargetServer({ port: targetPort, useSsl: false }); + await targetServer.listen(); + + // Setup HTTPS proxy with SOCKS5 upstream (with auth) + // Note: URL-encode '@' in username: proxy-ch@in -> proxy-ch%40in + const httpsProxyServer = new Server({ + port: httpsProxyPort, + serverType: 'https', + httpsOptions: { + key: sslKey, + cert: sslCrt, + maxCachedSessions: 0, // Critical for determinism + }, + prepareRequestFunction: () => ({ + upstreamProxyUrl: `socks5://proxy-ch%40in:rules!@127.0.0.1:${socksPort}`, + }), + verbose: false, + }); + await httpsProxyServer.listen(); + + try { + const statsPromise = awaitConnectionStats(httpsProxyServer); + + // Make GET request through HTTPS proxy (which routes via SOCKS5 with auth) + const agent = createNonCachingAgent({ rejectUnauthorized: false }); + const { response, body } = await requestPromised({ + url: `http://127.0.0.1:${targetPort}`, + proxy: `https://127.0.0.1:${httpsProxyPort}`, + agent, + }); + + expect(response.statusCode).to.equal(200); + expect(body).to.equal('It works!'); + + const stats = await statsPromise; + + // Note: SOCKS5 authentication adds ~21 bytes to target bytes vs no-auth test + // This overhead is visible in trgTxBytes (94 vs 73) and trgRxBytes (185 vs 183) + // Auth bytes: username/password exchange during SOCKS5 handshake + + expect(stats).to.deep.include(EXPECTED_SOCKS5_GET_AUTH_STATS); + + agent.destroy(); + } finally { + await new Promise((resolve) => socksServer.close(resolve)); + await targetServer.close(); + await httpsProxyServer.close(); + } + }); +}); + + // TODO: consider to add in future + // 1. Upstream Combinations + // - HTTPS proxy -> HTTPS upstream + // 2. Very Large Transfers + // - Transfer >100MB, validate byte counters don't overflow + diff --git a/test/utils/certificate_generator.js b/test/utils/certificate_generator.js new file mode 100644 index 00000000..16311ac2 --- /dev/null +++ b/test/utils/certificate_generator.js @@ -0,0 +1,58 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +/** + * Load certificate fixtures from the test/fixtures/certificates directory + * @param {string} type - Certificate type: 'valid', 'expired', 'hostname-mismatch', 'invalid-chain' + * @returns {{ key: Buffer, cert: Buffer, ca?: Buffer }} Certificate key and cert pair + */ +exports.loadCertificate = (type) => { + const certDir = path.join(__dirname, '../fixtures/certificates', type); + + const result = { + key: fs.readFileSync(path.join(certDir, type === 'invalid-chain' ? 'leaf-key.pem' : 'key.pem')), + cert: fs.readFileSync(path.join(certDir, type === 'invalid-chain' ? 'leaf-cert.pem' : 'cert.pem')), + }; + + // For invalid-chain, also load the root CA (but not the intermediate, which is missing) + if (type === 'invalid-chain') { + result.ca = fs.readFileSync(path.join(certDir, 'root-ca.pem')); + } + + return result; +}; + +/** + * Verify certificate properties using Node.js crypto.X509Certificate + * @param {Buffer|string} cert - Certificate in PEM format + * @returns {Object} Certificate properties + */ +exports.verifyCertificate = (cert) => { + const x509 = new crypto.X509Certificate(cert); + + return { + subject: x509.subject, + issuer: x509.issuer, + validFrom: x509.validFrom, + validTo: x509.validTo, + subjectAltName: x509.subjectAltName, + isExpired: Date.now() > new Date(x509.validTo), + serialNumber: x509.serialNumber, + fingerprint: x509.fingerprint, + fingerprint256: x509.fingerprint256, + }; +}; + +/** + * Check if certificate matches hostname + * @param {Buffer|string} cert - Certificate in PEM format + * @param {string} hostname - Hostname to check + * @returns {boolean} True if certificate matches hostname + */ +exports.certificateMatchesHostname = (cert, hostname) => { + const x509 = new crypto.X509Certificate(cert); + // checkHost returns undefined if the certificate doesn't match + // Returns the hostname if it matches + return x509.checkHost(hostname) !== undefined; +}; diff --git a/tsconfig.json b/tsconfig.json index 577dddbb..595c581a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { - "extends": "@apify/tsconfig", - "compilerOptions": { - "outDir": "dist" - }, - "include": [ - "src" - ] + "extends": "@apify/tsconfig", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "src" + ] }