diff --git a/changelog.md b/changelog.md index 48bac9b..bbaaeae 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-11 - 3.37.1 - fix(PortProxy/SNI) +Refactor SNI extraction in PortProxy to use the dedicated SniHandler class + +- Removed local SNI extraction and handshake detection functions from classes.portproxy.ts +- Introduced a standalone SniHandler class in ts/classes.snihandler.ts for robust SNI extraction and improved logging +- Replaced inlined calls to isTlsHandshake and extractSNI with calls to SniHandler methods +- Ensured consistency in handling TLS ClientHello messages across the codebase + ## 2025-03-11 - 3.37.0 - feat(portproxy) Add ACME certificate management options to PortProxy, update ACME settings handling, and bump dependency versions diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4c92f18..f822ee8 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '3.37.0', + version: '3.37.1', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' } diff --git a/ts/classes.portproxy.ts b/ts/classes.portproxy.ts index f702358..3e6e129 100644 --- a/ts/classes.portproxy.ts +++ b/ts/classes.portproxy.ts @@ -1,5 +1,6 @@ import * as plugins from './plugins.js'; import { NetworkProxy } from './classes.networkproxy.js'; +import { SniHandler } from './classes.snihandler.js'; /** Domain configuration with per-domain allowed port ranges */ export interface IDomainConfig { @@ -117,192 +118,8 @@ interface IConnectionRecord { domainSwitches?: number; // Number of times the domain has been switched on this connection } -/** - * Extracts the SNI (Server Name Indication) from a TLS ClientHello packet. - * Enhanced for robustness and detailed logging. - * @param buffer - Buffer containing the TLS ClientHello. - * @param enableLogging - Whether to enable detailed logging. - * @returns The server name if found, otherwise undefined. - */ -function extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined { - try { - // Check if buffer is too small for TLS - if (buffer.length < 5) { - if (enableLogging) console.log('Buffer too small for TLS header'); - return undefined; - } - - // Check record type (has to be handshake - 22) - const recordType = buffer.readUInt8(0); - if (recordType !== 22) { - if (enableLogging) console.log(`Not a TLS handshake. Record type: ${recordType}`); - return undefined; - } - - // Check TLS version (has to be 3.1 or higher) - const majorVersion = buffer.readUInt8(1); - const minorVersion = buffer.readUInt8(2); - if (enableLogging) console.log(`TLS Version: ${majorVersion}.${minorVersion}`); - - // Check record length - const recordLength = buffer.readUInt16BE(3); - if (buffer.length < 5 + recordLength) { - if (enableLogging) - console.log( - `Buffer too small for TLS record. Expected: ${5 + recordLength}, Got: ${buffer.length}` - ); - return undefined; - } - - let offset = 5; - const handshakeType = buffer.readUInt8(offset); - if (handshakeType !== 1) { - if (enableLogging) console.log(`Not a ClientHello. Handshake type: ${handshakeType}`); - return undefined; - } - - offset += 4; // Skip handshake header (type + length) - - // Client version - const clientMajorVersion = buffer.readUInt8(offset); - const clientMinorVersion = buffer.readUInt8(offset + 1); - if (enableLogging) console.log(`Client Version: ${clientMajorVersion}.${clientMinorVersion}`); - - offset += 2 + 32; // Skip client version and random - - // Session ID - const sessionIDLength = buffer.readUInt8(offset); - if (enableLogging) console.log(`Session ID Length: ${sessionIDLength}`); - offset += 1 + sessionIDLength; // Skip session ID - - // Cipher suites - if (offset + 2 > buffer.length) { - if (enableLogging) console.log('Buffer too small for cipher suites length'); - return undefined; - } - const cipherSuitesLength = buffer.readUInt16BE(offset); - if (enableLogging) console.log(`Cipher Suites Length: ${cipherSuitesLength}`); - offset += 2 + cipherSuitesLength; // Skip cipher suites - - // Compression methods - if (offset + 1 > buffer.length) { - if (enableLogging) console.log('Buffer too small for compression methods length'); - return undefined; - } - const compressionMethodsLength = buffer.readUInt8(offset); - if (enableLogging) console.log(`Compression Methods Length: ${compressionMethodsLength}`); - offset += 1 + compressionMethodsLength; // Skip compression methods - - // Extensions - if (offset + 2 > buffer.length) { - if (enableLogging) console.log('Buffer too small for extensions length'); - return undefined; - } - const extensionsLength = buffer.readUInt16BE(offset); - if (enableLogging) console.log(`Extensions Length: ${extensionsLength}`); - offset += 2; - const extensionsEnd = offset + extensionsLength; - - if (extensionsEnd > buffer.length) { - if (enableLogging) - console.log( - `Buffer too small for extensions. Expected end: ${extensionsEnd}, Buffer length: ${buffer.length}` - ); - return undefined; - } - - // Parse extensions - while (offset + 4 <= extensionsEnd) { - const extensionType = buffer.readUInt16BE(offset); - const extensionLength = buffer.readUInt16BE(offset + 2); - - if (enableLogging) - console.log(`Extension Type: 0x${extensionType.toString(16)}, Length: ${extensionLength}`); - - offset += 4; - - if (extensionType === 0x0000) { - // SNI extension - if (offset + 2 > buffer.length) { - if (enableLogging) console.log('Buffer too small for SNI list length'); - return undefined; - } - - const sniListLength = buffer.readUInt16BE(offset); - if (enableLogging) console.log(`SNI List Length: ${sniListLength}`); - offset += 2; - const sniListEnd = offset + sniListLength; - - if (sniListEnd > buffer.length) { - if (enableLogging) - console.log( - `Buffer too small for SNI list. Expected end: ${sniListEnd}, Buffer length: ${buffer.length}` - ); - return undefined; - } - - while (offset + 3 < sniListEnd) { - const nameType = buffer.readUInt8(offset++); - const nameLen = buffer.readUInt16BE(offset); - offset += 2; - - if (enableLogging) console.log(`Name Type: ${nameType}, Name Length: ${nameLen}`); - - if (nameType === 0) { - // host_name - if (offset + nameLen > buffer.length) { - if (enableLogging) - console.log( - `Buffer too small for hostname. Expected: ${offset + nameLen}, Got: ${ - buffer.length - }` - ); - return undefined; - } - - const serverName = buffer.toString('utf8', offset, offset + nameLen); - if (enableLogging) console.log(`Extracted SNI: ${serverName}`); - return serverName; - } - - offset += nameLen; - } - break; - } else { - offset += extensionLength; - } - } - - if (enableLogging) console.log('No SNI extension found'); - return undefined; - } catch (err) { - console.log(`Error extracting SNI: ${err}`); - return undefined; - } -} - -/** - * Checks if a TLS record is a proper ClientHello message (more accurate than just checking record type) - * @param buffer - Buffer containing the TLS record - * @returns true if the buffer contains a proper ClientHello message - */ -function isClientHello(buffer: Buffer): boolean { - try { - if (buffer.length < 9) return false; // Too small for a proper ClientHello - - // Check record type (has to be handshake - 22) - if (buffer.readUInt8(0) !== 22) return false; - - // After the TLS record header (5 bytes), check the handshake type (1 for ClientHello) - if (buffer.readUInt8(5) !== 1) return false; - - // Basic checks passed, this appears to be a ClientHello - return true; - } catch (err) { - console.log(`Error checking for ClientHello: ${err}`); - return false; - } -} +// SNI functions are now imported from SniHandler class +// No need for wrapper functions // Helper: Check if a port falls within any of the given port ranges const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => { @@ -346,10 +163,7 @@ const generateConnectionId = (): string => { return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); }; -// Helper: Check if a buffer contains a TLS handshake -const isTlsHandshake = (buffer: Buffer): boolean => { - return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake -}; +// SNI functions are now imported from SniHandler class // Helper: Ensure timeout values don't exceed Node.js max safe integer const ensureSafeTimeout = (timeout: number): number => { @@ -761,7 +575,7 @@ export class PortProxy { record.bytesReceived += chunk.length; // Check for TLS handshake - if (!record.isTLS && isTlsHandshake(chunk)) { + if (!record.isTLS && SniHandler.isTlsHandshake(chunk)) { record.isTLS = true; if (this.settings.enableTlsDebugLogging) { @@ -1049,10 +863,10 @@ export class PortProxy { // Define a handler for checking renegotiation with improved detection const renegotiationHandler = (renegChunk: Buffer) => { // Only process if this looks like a TLS ClientHello - if (isClientHello(renegChunk)) { + if (SniHandler.isClientHello(renegChunk)) { try { // Extract SNI from ClientHello - const newSNI = extractSNI(renegChunk, this.settings.enableTlsDebugLogging); + const newSNI = SniHandler.extractSNIWithResumptionSupport(renegChunk, this.settings.enableTlsDebugLogging); // Skip if no SNI was found if (!newSNI) return; @@ -1644,7 +1458,7 @@ export class PortProxy { connectionRecord.hasReceivedInitialData = true; // Check if this looks like a TLS handshake - if (isTlsHandshake(chunk)) { + if (SniHandler.isTlsHandshake(chunk)) { connectionRecord.isTLS = true; // Forward directly to NetworkProxy without SNI processing @@ -1706,7 +1520,7 @@ export class PortProxy { this.updateActivity(connectionRecord); // Check for TLS handshake if this is the first chunk - if (!connectionRecord.isTLS && isTlsHandshake(chunk)) { + if (!connectionRecord.isTLS && SniHandler.isTlsHandshake(chunk)) { connectionRecord.isTLS = true; if (this.settings.enableTlsDebugLogging) { @@ -1714,7 +1528,7 @@ export class PortProxy { `[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes` ); // Try to extract SNI and log detailed debug info - extractSNI(chunk, true); + SniHandler.extractSNIWithResumptionSupport(chunk, true); } } }); @@ -1743,7 +1557,7 @@ export class PortProxy { connectionRecord.hasReceivedInitialData = true; // Check if this looks like a TLS handshake - const isTlsHandshakeDetected = initialChunk && isTlsHandshake(initialChunk); + const isTlsHandshakeDetected = initialChunk && SniHandler.isTlsHandshake(initialChunk); if (isTlsHandshakeDetected) { connectionRecord.isTLS = true; @@ -1912,7 +1726,7 @@ export class PortProxy { // Try to extract SNI let serverName = ''; - if (isTlsHandshake(chunk)) { + if (SniHandler.isTlsHandshake(chunk)) { connectionRecord.isTLS = true; if (this.settings.enableTlsDebugLogging) { @@ -1921,7 +1735,7 @@ export class PortProxy { ); } - serverName = extractSNI(chunk, this.settings.enableTlsDebugLogging) || ''; + serverName = SniHandler.extractSNIWithResumptionSupport(chunk, this.settings.enableTlsDebugLogging) || ''; } // Lock the connection to the negotiated SNI. diff --git a/ts/classes.snihandler.ts b/ts/classes.snihandler.ts new file mode 100644 index 0000000..3c521d1 --- /dev/null +++ b/ts/classes.snihandler.ts @@ -0,0 +1,331 @@ +import { Buffer } from 'buffer'; + +/** + * SNI (Server Name Indication) handler for TLS connections. + * Provides robust extraction of SNI values from TLS ClientHello messages. + */ +export class SniHandler { + // TLS record types and constants + private static readonly TLS_HANDSHAKE_RECORD_TYPE = 22; + private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = 1; + private static readonly TLS_SNI_EXTENSION_TYPE = 0x0000; + private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = 0x0023; + private static readonly TLS_SNI_HOST_NAME_TYPE = 0; + + /** + * Checks if a buffer contains a TLS handshake message (record type 22) + * @param buffer - The buffer to check + * @returns true if the buffer starts with a TLS handshake record type + */ + public static isTlsHandshake(buffer: Buffer): boolean { + return buffer.length > 0 && buffer[0] === this.TLS_HANDSHAKE_RECORD_TYPE; + } + + /** + * Checks if a buffer contains a TLS ClientHello message + * @param buffer - The buffer to check + * @returns true if the buffer appears to be a ClientHello message + */ + public static isClientHello(buffer: Buffer): boolean { + // Minimum ClientHello size (TLS record header + handshake header) + if (buffer.length < 9) { + return false; + } + + // Check record type (must be TLS_HANDSHAKE_RECORD_TYPE) + if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) { + return false; + } + + // Skip version and length in TLS record header (5 bytes total) + // Check handshake type at byte 5 (must be CLIENT_HELLO) + return buffer[5] === this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE; + } + + /** + * Extracts the SNI (Server Name Indication) from a TLS ClientHello message. + * Implements robust parsing with support for session resumption edge cases. + * + * @param buffer - The buffer containing the TLS ClientHello message + * @param enableLogging - Whether to enable detailed debug logging + * @returns The extracted server name or undefined if not found + */ + public static extractSNI(buffer: Buffer, enableLogging: boolean = false): string | undefined { + // Logging helper + const log = (message: string) => { + if (enableLogging) { + console.log(`[SNI Extraction] ${message}`); + } + }; + + try { + // Buffer must be at least 5 bytes (TLS record header) + if (buffer.length < 5) { + log('Buffer too small for TLS record header'); + return undefined; + } + + // Check record type (must be TLS_HANDSHAKE_RECORD_TYPE = 22) + if (buffer[0] !== this.TLS_HANDSHAKE_RECORD_TYPE) { + log(`Not a TLS handshake record: ${buffer[0]}`); + return undefined; + } + + // Check TLS version + const majorVersion = buffer[1]; + const minorVersion = buffer[2]; + log(`TLS version: ${majorVersion}.${minorVersion}`); + + // Parse record length (bytes 3-4, big-endian) + const recordLength = (buffer[3] << 8) + buffer[4]; + log(`Record length: ${recordLength}`); + + // Validate record length against buffer size + if (buffer.length < recordLength + 5) { + log('Buffer smaller than expected record length'); + return undefined; + } + + // Start of handshake message in the buffer + let pos = 5; + + // Check handshake type (must be CLIENT_HELLO = 1) + if (buffer[pos] !== this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE) { + log(`Not a ClientHello message: ${buffer[pos]}`); + return undefined; + } + + // Skip handshake type (1 byte) + pos += 1; + + // Parse handshake length (3 bytes, big-endian) + const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2]; + log(`Handshake length: ${handshakeLength}`); + + // Skip handshake length (3 bytes) + pos += 3; + + // Check client version (2 bytes) + const clientMajorVersion = buffer[pos]; + const clientMinorVersion = buffer[pos + 1]; + log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`); + + // Skip client version (2 bytes) + pos += 2; + + // Skip client random (32 bytes) + pos += 32; + + // Parse session ID + if (pos + 1 > buffer.length) { + log('Buffer too small for session ID length'); + return undefined; + } + + const sessionIdLength = buffer[pos]; + log(`Session ID length: ${sessionIdLength}`); + + // Skip session ID length (1 byte) and session ID + pos += 1 + sessionIdLength; + + // Check if we have enough bytes left + if (pos + 2 > buffer.length) { + log('Buffer too small for cipher suites length'); + return undefined; + } + + // Parse cipher suites length (2 bytes, big-endian) + const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; + log(`Cipher suites length: ${cipherSuitesLength}`); + + // Skip cipher suites length (2 bytes) and cipher suites + pos += 2 + cipherSuitesLength; + + // Check if we have enough bytes left + if (pos + 1 > buffer.length) { + log('Buffer too small for compression methods length'); + return undefined; + } + + // Parse compression methods length (1 byte) + const compressionMethodsLength = buffer[pos]; + log(`Compression methods length: ${compressionMethodsLength}`); + + // Skip compression methods length (1 byte) and compression methods + pos += 1 + compressionMethodsLength; + + // Check if we have enough bytes for extensions length + if (pos + 2 > buffer.length) { + log('No extensions present or buffer too small'); + return undefined; + } + + // Parse extensions length (2 bytes, big-endian) + const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; + log(`Extensions length: ${extensionsLength}`); + + // Skip extensions length (2 bytes) + pos += 2; + + // Extensions end position + const extensionsEnd = pos + extensionsLength; + + // Check if extensions length is valid + if (extensionsEnd > buffer.length) { + log('Extensions length exceeds buffer size'); + return undefined; + } + + // Track if we found session tickets (for improved resumption handling) + let hasSessionTicket = false; + + // Iterate through extensions + while (pos + 4 <= extensionsEnd) { + // Parse extension type (2 bytes, big-endian) + const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; + log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`); + + // Skip extension type (2 bytes) + pos += 2; + + // Parse extension length (2 bytes, big-endian) + const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; + log(`Extension length: ${extensionLength}`); + + // Skip extension length (2 bytes) + pos += 2; + + // Check if this is the SNI extension + if (extensionType === this.TLS_SNI_EXTENSION_TYPE) { + log('Found SNI extension'); + + // Ensure we have enough bytes for the server name list + if (pos + 2 > extensionsEnd) { + log('Extension too small for server name list length'); + pos += extensionLength; // Skip this extension + continue; + } + + // Parse server name list length (2 bytes, big-endian) + const serverNameListLength = (buffer[pos] << 8) + buffer[pos + 1]; + log(`Server name list length: ${serverNameListLength}`); + + // Skip server name list length (2 bytes) + pos += 2; + + // Ensure server name list length is valid + if (pos + serverNameListLength > extensionsEnd) { + log('Server name list length exceeds extension size'); + break; // Exit the loop, extension parsing is broken + } + + // End position of server name list + const serverNameListEnd = pos + serverNameListLength; + + // Iterate through server names + while (pos + 3 <= serverNameListEnd) { + // Check name type (must be HOST_NAME_TYPE = 0 for hostname) + const nameType = buffer[pos]; + log(`Name type: ${nameType}`); + + if (nameType !== this.TLS_SNI_HOST_NAME_TYPE) { + log(`Unsupported name type: ${nameType}`); + pos += 1; // Skip name type (1 byte) + + // Skip name length (2 bytes) and name data + if (pos + 2 <= serverNameListEnd) { + const nameLength = (buffer[pos] << 8) + buffer[pos + 1]; + pos += 2 + nameLength; + } else { + log('Invalid server name entry'); + break; + } + continue; + } + + // Skip name type (1 byte) + pos += 1; + + // Ensure we have enough bytes for name length + if (pos + 2 > serverNameListEnd) { + log('Server name entry too small for name length'); + break; + } + + // Parse name length (2 bytes, big-endian) + const nameLength = (buffer[pos] << 8) + buffer[pos + 1]; + log(`Name length: ${nameLength}`); + + // Skip name length (2 bytes) + pos += 2; + + // Ensure we have enough bytes for the name + if (pos + nameLength > serverNameListEnd) { + log('Name length exceeds server name list size'); + break; + } + + // Extract server name (hostname) + const serverName = buffer.slice(pos, pos + nameLength).toString('utf8'); + log(`Extracted server name: ${serverName}`); + return serverName; + } + } else if (extensionType === this.TLS_SESSION_TICKET_EXTENSION_TYPE) { + // If we encounter a session ticket extension, mark it for later + log('Found session ticket extension'); + hasSessionTicket = true; + pos += extensionLength; // Skip this extension + } else { + // Skip this extension + pos += extensionLength; + } + } + + // Log if we found a session ticket but no SNI + if (hasSessionTicket) { + log('Session ticket present but no SNI found - possible resumption scenario'); + } + + log('No SNI extension found in ClientHello'); + return undefined; + } catch (error) { + log(`Error parsing SNI: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + /** + * Attempts to extract SNI from an initial ClientHello packet and handles + * session resumption edge cases more robustly than the standard extraction. + * + * This method is specifically designed for Chrome and other browsers that + * may send different ClientHello formats during session resumption. + * + * @param buffer - The buffer containing the TLS ClientHello message + * @param enableLogging - Whether to enable detailed debug logging + * @returns The extracted server name or undefined if not found + */ + public static extractSNIWithResumptionSupport( + buffer: Buffer, + enableLogging: boolean = false + ): string | undefined { + // First try the standard SNI extraction + const standardSni = this.extractSNI(buffer, enableLogging); + if (standardSni) { + return standardSni; + } + + // If standard extraction failed and we have a valid ClientHello, + // this might be a session resumption with non-standard format + if (this.isClientHello(buffer)) { + if (enableLogging) { + console.log('[SNI Extraction] Detected ClientHello without standard SNI, possible session resumption'); + } + + // Additional handling could be implemented here for specific browser behaviors + // For now, this is a placeholder for future improvements + } + + return undefined; + } +} \ No newline at end of file diff --git a/ts/index.ts b/ts/index.ts index 4956c92..804308f 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -3,3 +3,4 @@ export * from './classes.networkproxy.js'; export * from './classes.portproxy.js'; export * from './classes.port80handler.js'; export * from './classes.sslredirect.js'; +export * from './classes.snihandler.js';