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 44934f7a..239334a5 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,11 +110,128 @@ 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. + ## SOCKS support SOCKS protocol is supported for versions 4 and 5, specifically: `['socks', 'socks4', 'socks4a', 'socks5', 'socks5h']`, where `socks` will default to version 5. 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 c3730fad..f55683a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxy-chain", - "version": "2.5.9", + "version": "2.6.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 41fba80d..d186031f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,9 @@ import { Buffer } from 'node:buffer'; import type dns from 'node:dns'; import { EventEmitter } from 'node:events'; import http from 'node:http'; +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'; @@ -18,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'; @@ -40,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; }; @@ -91,10 +128,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; @@ -107,7 +166,9 @@ export class Server extends EventEmitter { verbose: boolean; - server: http.Server; + server: http.Server | https.Server; + + serverType: 'http' | 'https'; lastHandlerId: number; @@ -119,6 +180,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: @@ -149,13 +213,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) { @@ -169,11 +227,44 @@ export class Server extends EventEmitter { this.authRealm = options.authRealm || DEFAULT_AUTH_REALM; this.verbose = !!options.verbose; - this.server = http.createServer(); + // Create server based on type + 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) + // This prevents users from accidentally configuring insecure TLS settings + const secureDefaults: https.ServerOptions = { + ...HTTPS_DEFAULTS, + honorCipherOrder: true, // Server chooses cipher (prevents downgrade attacks) + ...options.httpsOptions, // User options override defaults + }; + + this.server = https.createServer(secureDefaults); + this.serverType = 'https'; + } else { + this.server = http.createServer(); + this.serverType = 'http'; + } + + // 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 = { @@ -195,14 +286,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. @@ -243,11 +358,32 @@ 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 there are no user-provided error handlers + if (this.listenerCount('error') === 0) { 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; + } + } } /** @@ -613,16 +749,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 82d3daed..15452b90 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=140.0.7339.185-1~deb12u1 \ +RUN apt-get update && apt-get install -y --no-install-recommends chromium=141.0.7390.54-1~deb12u1 \ && rm -rf /var/lib/apt/lists/* ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true @@ -8,15 +8,11 @@ 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 + +ENTRYPOINT [ "npm", "test", "--" ] -CMD ["npm", "test"] 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..96a8d969 --- /dev/null +++ b/test/https_edge_cases.js @@ -0,0 +1,636 @@ +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); +})(); + +/** + * Helper function to make HTTP requests through proxy + */ +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; + + before(async () => { + freePorts = await portastic.find({ min: 50000, max: 50500 }); + }); + + afterEach(async () => { + 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; + } + + // Create HTTPS proxy with expired certificate + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: expiredCert.key, + cert: expiredCert.cert, + }, + }); + await proxyServer.listen(); + + // Create target HTTP server + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + // Attempt to connect with strict SSL validation + try { + await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `https://127.0.0.1:${proxyPort}`, + strictSSL: true, + rejectUnauthorized: true, + }); + expect.fail('Should have rejected expired certificate'); + } catch (error) { + // Should fail with certificate error + expect(error.message).to.match(/certificate|CERT|SSL|TLS/i); + } + }); + + it('accepts HTTPS proxy with expired certificate (ignore SSL errors)', async () => { + const expiredCert = loadCertificate('expired'); + const proxyPort = freePorts.shift(); + + // Create HTTPS proxy with expired certificate + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: expiredCert.key, + cert: expiredCert.cert, + }, + }); + await proxyServer.listen(); + + // Create target HTTP server + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + // Connect with SSL validation disabled + 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 validCert = loadCertificate('valid'); + + // Create upstream HTTPS proxy with expired cert + const upstreamPort = freePorts.shift(); + const upstreamProxyServer = new Server({ + port: upstreamPort, + serverType: 'https', + httpsOptions: { + key: expiredCert.key, + cert: expiredCert.cert, + }, + }); + await upstreamProxyServer.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(); + + // Create target HTTP server + 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 or similar + const response = await requestPromised({ + url: `http://127.0.0.1:${targetPort}/hello-world`, + proxy: `http://127.0.0.1:${mainProxyPort}`, + }); + + // Expect error status code (599 for TLS errors) + expect(response.statusCode).to.be.oneOf([599, 502, 503]); + + // Cleanup upstream proxy + await upstreamProxyServer.close(true); + }); + }); + + 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; + } + + // Create HTTPS proxy with hostname mismatch certificate + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: mismatchCert.key, + cert: mismatchCert.cert, + }, + }); + await proxyServer.listen(); + + // Create target HTTP server + 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, + }); + expect.fail('Should have rejected certificate with hostname mismatch'); + } catch (error) { + // Should fail with hostname/altname error + expect(error.message).to.match(/certificate|hostname|CERT_ALTNAME|SSL|TLS/i); + } + }); + + it('accepts certificate with correct hostname (ignore SSL)', async () => { + const mismatchCert = loadCertificate('hostname-mismatch'); + const proxyPort = freePorts.shift(); + + // Create HTTPS proxy with hostname mismatch certificate + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: mismatchCert.key, + cert: mismatchCert.cert, + }, + }); + await proxyServer.listen(); + + // Create target HTTP server + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + // Connect with SSL validation disabled + 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('Multi-Stage Certificate Validation', () => { + it('validates certificates independently at each proxy hop', async () => { + const validCert = loadCertificate('valid'); + const expiredCert = loadCertificate('expired'); + + // Create HTTPS target server with valid certificate + 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(); + const upstreamProxy = new Server({ + port: upstreamPort, + serverType: 'http', + }); + await upstreamProxy.listen(); + + // Create main HTTPS proxy with expired certificate + 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, + }); + expect.fail('Should have rejected expired certificate at proxy level'); + } catch (error) { + // Should fail at proxy level, not target level + expect(error.message).to.match(/certificate|CERT|SSL|TLS/i); + } + + // Cleanup + await upstreamProxy.close(true); + }); + + it('handles HTTPS proxy with HTTP target (protocol isolation)', async () => { + const validCert = loadCertificate('valid'); + + // Create HTTP target (plain HTTP, no SSL) + const targetPort = freePorts.shift(); + targetServer = new TargetServer({ + port: targetPort, + useSsl: false, + }); + await targetServer.listen(); + + // Create HTTPS proxy with valid certificate + const proxyPort = freePorts.shift(); + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + // Connect through HTTPS proxy to HTTP target + // This validates protocol isolation: + // 1. Client-to-proxy connection is encrypted (HTTPS) + // 2. Proxy-to-target connection is plain HTTP + // 3. The two connections are independent + // + // NOTE: Testing HTTPS proxy → HTTPS target with the `request` library + // is not possible due to a bug in tunnel-agent (request/request#2762) + // where rejectUnauthorized is not passed to the proxy connection. + // TODO: we should migrate to impit. + 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 Edge Cases - TLS Version Negotiation', function () { + this.timeout(30000); + + let freePorts; + let proxyServer; + + before(async () => { + freePorts = await portastic.find({ min: 50500, max: 51000 }); + }); + + 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(); + + // Attempt TLS 1.0 connection + 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(); + + // Attempt TLS 1.1 connection + 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(); + + // Create HTTPS proxy with default TLS settings + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + // Attempt TLS 1.2 connection + 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(); + + // Create HTTPS proxy with default TLS settings + proxyServer = new Server({ + port: proxyPort, + serverType: 'https', + httpsOptions: { + key: validCert.key, + cert: validCert.cert, + }, + }); + await proxyServer.listen(); + + // Attempt TLS 1.3 connection + 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'); + }); +}); + +describe('HTTPS Edge Cases - Cipher Suite Handling', function () { + this.timeout(30000); + + let freePorts; + let proxyServer; + + before(async () => { + freePorts = await portastic.find({ min: 51000, max: 51500 }); + }); + + afterEach(async () => { + if (proxyServer) { + await proxyServer.close(true); + proxyServer = null; + } + }); + + it('accepts clients with strong ciphers', async () => { + const validCert = loadCertificate('valid'); + const proxyPort = freePorts.shift(); + + // Create HTTPS proxy with strong cipher requirements + 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); + }); +}); + + +/** + * Test TLS handshake with specific version and cipher configuration + * @param {Object} options - TLS connection options + * @param {string} options.host - Host to connect to + * @param {number} options.port - Port to connect to + * @param {string} [options.minVersion] - Minimum TLS version (e.g., 'TLSv1', 'TLSv1.2') + * @param {string} [options.maxVersion] - Maximum TLS version (e.g., 'TLSv1.3') + * @param {string} [options.ciphers] - Cipher suite string + * @param {boolean} [options.rejectUnauthorized=false] - Whether to reject unauthorized certificates + * @param {number} [options.timeout=5000] - Connection timeout in milliseconds + * @returns {Promise} Result object with success status, protocol, cipher, or error + */ +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, + }); + + 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..f75f45a8 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'); @@ -109,9 +110,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 +136,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 +169,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 +426,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 +461,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 +545,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 +573,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 +698,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 +723,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 +827,49 @@ 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); + } + }); + }); + // TODO: Test streamed GET // _it('handles large streamed GET response', test1MAChars); @@ -826,7 +916,8 @@ const createTestSuite = ({ }); }); - if (!mainProxyAuth || (mainProxyAuth.username && mainProxyAuth.password)) { + // TODO: Skip puppeteer tests for HTTPS proxy as Puppeteer doesn't support HTTPS proxies well + if (mainProxyServerType !== 'https' && (!mainProxyAuth || (mainProxyAuth.username && mainProxyAuth.password))) { it('handles GET request using puppeteer', async () => { const phantomUrl = `${useSsl ? 'https' : 'http'}://${LOCALHOST_TEST}:${targetServerPort}/hello-world`; const response = await puppeteerGet(phantomUrl, mainProxyUrl); @@ -834,7 +925,8 @@ const createTestSuite = ({ }); } - if (!useSsl && mainProxyAuth && mainProxyAuth.username && mainProxyAuth.password) { + // TODO: same here + if (mainProxyServerType !== 'https' && !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}`); @@ -856,7 +948,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 +1020,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 +1082,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 +1119,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) => { @@ -1264,7 +1377,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 +1453,150 @@ it('supports localAddress', async () => { } }); +it('prevents duplicate socket error handling (regression test for race condition)', async () => { + // This test proves that the socket error handler race condition is fixed. + // Before the fix: Multiple error events on same socket would cause duplicate log entries + // After the fix: The proxyChainErrorHandled flag prevents duplicate handling + + const server = new Server({ + port: 0, + verbose: false, // Disable verbose to control logging ourselves + }); + + await server.listen(); + + try { + // Track how many times error handler logic executes + let errorHandlerExecutionCount = 0; + const loggedErrors = []; + + // Monkey-patch the log method to capture error logs + const originalLog = server.log.bind(server); + server.log = (connectionId, message) => { + if (message.includes('Source socket emitted error:')) { + errorHandlerExecutionCount++; + loggedErrors.push(message); + } + originalLog(connectionId, message); + }; + + // 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 + // The server tracks all connections, we can get the most recent one + 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 + // This simulates the race condition where multiple errors fire + const testError1 = new Error('Test error 1'); + const testError2 = new Error('Test error 2'); + const testError3 = new Error('Test error 3'); + + serverSocket.emit('error', testError1); + serverSocket.emit('error', testError2); + serverSocket.emit('error', testError3); + + // Give time for all error handlers to fire + await wait(50); + + // The flag should be set after first error + expect(serverSocket.proxyChainErrorHandled).to.equal(true); + + // Error handler logic should execute exactly ONCE, not three times + expect(errorHandlerExecutionCount).to.equal(1); + + // Only ONE error should be logged, not three + expect(loggedErrors.length).to.equal(1); + expect(loggedErrors[0]).to.include('Test error 1'); // First error is logged + + // Subsequent errors should be silently ignored (not logged) + expect(loggedErrors[0]).to.not.include('Test error 2'); + expect(loggedErrors[0]).to.not.include('Test error 3'); + + socket.destroy(); + } finally { + await server.close(); + } +}); + +it('socket error handler respects user-provided error handlers', async () => { + // Verify that when user provides error handler, the default logging is suppressed + // but the flag still prevents duplicate handling + + let userErrorHandlerCallCount = 0; + + const server = new Server({ + port: 0, + verbose: false, + }); + + // User provides custom error handler + server.on('error', () => { + userErrorHandlerCallCount++; + }); + + await server.listen(); + + try { + let defaultLogCallCount = 0; + const originalLog = server.log.bind(server); + server.log = (connectionId, message) => { + if (message.includes('Source socket emitted error:')) { + defaultLogCallCount++; + } + 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]; + + // Emit multiple errors + serverSocket.emit('error', new Error('User handler test 1')); + serverSocket.emit('error', new Error('User handler test 2')); + + await wait(50); + + // When user provides error handler, default logging should NOT happen + expect(defaultLogCallCount).to.equal(0); + + // Flag should still prevent duplicate handling + expect(serverSocket.proxyChainErrorHandled).to.equal(true); + + socket.destroy(); + } finally { + await server.close(); + } +}); + it('supports https proxy relay', async () => { const target = https.createServer(() => { }); @@ -1579,6 +1836,10 @@ describe('supports ignoreUpstreamProxyCertificate', () => { }); // Run all combinations of test parameters +const mainProxyServerTypeVariants = [ + 'http', + 'https', +]; const useSslVariants = [ false, true, @@ -1601,48 +1862,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/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" + ] }