fix(PortProxy/SNI): Refactor SNI extraction in PortProxy to use the dedicated SniHandler class

This commit is contained in:
Philipp Kunz 2025-03-11 17:01:07 +00:00
parent d81cf94876
commit 87d26c86a1
5 changed files with 354 additions and 200 deletions

View File

@ -1,5 +1,13 @@
# Changelog # 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) ## 2025-03-11 - 3.37.0 - feat(portproxy)
Add ACME certificate management options to PortProxy, update ACME settings handling, and bump dependency versions Add ACME certificate management options to PortProxy, update ACME settings handling, and bump dependency versions

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', 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.' 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.'
} }

View File

@ -1,5 +1,6 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import { NetworkProxy } from './classes.networkproxy.js'; import { NetworkProxy } from './classes.networkproxy.js';
import { SniHandler } from './classes.snihandler.js';
/** Domain configuration with per-domain allowed port ranges */ /** Domain configuration with per-domain allowed port ranges */
export interface IDomainConfig { export interface IDomainConfig {
@ -117,192 +118,8 @@ interface IConnectionRecord {
domainSwitches?: number; // Number of times the domain has been switched on this connection domainSwitches?: number; // Number of times the domain has been switched on this connection
} }
/** // SNI functions are now imported from SniHandler class
* Extracts the SNI (Server Name Indication) from a TLS ClientHello packet. // No need for wrapper functions
* 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;
}
}
// Helper: Check if a port falls within any of the given port ranges // Helper: Check if a port falls within any of the given port ranges
const isPortInRanges = (port: number, ranges: Array<{ from: number; to: number }>): boolean => { 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); return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}; };
// Helper: Check if a buffer contains a TLS handshake // SNI functions are now imported from SniHandler class
const isTlsHandshake = (buffer: Buffer): boolean => {
return buffer.length > 0 && buffer[0] === 22; // ContentType.handshake
};
// Helper: Ensure timeout values don't exceed Node.js max safe integer // Helper: Ensure timeout values don't exceed Node.js max safe integer
const ensureSafeTimeout = (timeout: number): number => { const ensureSafeTimeout = (timeout: number): number => {
@ -761,7 +575,7 @@ export class PortProxy {
record.bytesReceived += chunk.length; record.bytesReceived += chunk.length;
// Check for TLS handshake // Check for TLS handshake
if (!record.isTLS && isTlsHandshake(chunk)) { if (!record.isTLS && SniHandler.isTlsHandshake(chunk)) {
record.isTLS = true; record.isTLS = true;
if (this.settings.enableTlsDebugLogging) { if (this.settings.enableTlsDebugLogging) {
@ -1049,10 +863,10 @@ export class PortProxy {
// Define a handler for checking renegotiation with improved detection // Define a handler for checking renegotiation with improved detection
const renegotiationHandler = (renegChunk: Buffer) => { const renegotiationHandler = (renegChunk: Buffer) => {
// Only process if this looks like a TLS ClientHello // Only process if this looks like a TLS ClientHello
if (isClientHello(renegChunk)) { if (SniHandler.isClientHello(renegChunk)) {
try { try {
// Extract SNI from ClientHello // 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 // Skip if no SNI was found
if (!newSNI) return; if (!newSNI) return;
@ -1644,7 +1458,7 @@ export class PortProxy {
connectionRecord.hasReceivedInitialData = true; connectionRecord.hasReceivedInitialData = true;
// Check if this looks like a TLS handshake // Check if this looks like a TLS handshake
if (isTlsHandshake(chunk)) { if (SniHandler.isTlsHandshake(chunk)) {
connectionRecord.isTLS = true; connectionRecord.isTLS = true;
// Forward directly to NetworkProxy without SNI processing // Forward directly to NetworkProxy without SNI processing
@ -1706,7 +1520,7 @@ export class PortProxy {
this.updateActivity(connectionRecord); this.updateActivity(connectionRecord);
// Check for TLS handshake if this is the first chunk // Check for TLS handshake if this is the first chunk
if (!connectionRecord.isTLS && isTlsHandshake(chunk)) { if (!connectionRecord.isTLS && SniHandler.isTlsHandshake(chunk)) {
connectionRecord.isTLS = true; connectionRecord.isTLS = true;
if (this.settings.enableTlsDebugLogging) { if (this.settings.enableTlsDebugLogging) {
@ -1714,7 +1528,7 @@ export class PortProxy {
`[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes` `[${connectionId}] TLS handshake detected from ${remoteIP}, ${chunk.length} bytes`
); );
// Try to extract SNI and log detailed debug info // 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; connectionRecord.hasReceivedInitialData = true;
// Check if this looks like a TLS handshake // Check if this looks like a TLS handshake
const isTlsHandshakeDetected = initialChunk && isTlsHandshake(initialChunk); const isTlsHandshakeDetected = initialChunk && SniHandler.isTlsHandshake(initialChunk);
if (isTlsHandshakeDetected) { if (isTlsHandshakeDetected) {
connectionRecord.isTLS = true; connectionRecord.isTLS = true;
@ -1912,7 +1726,7 @@ export class PortProxy {
// Try to extract SNI // Try to extract SNI
let serverName = ''; let serverName = '';
if (isTlsHandshake(chunk)) { if (SniHandler.isTlsHandshake(chunk)) {
connectionRecord.isTLS = true; connectionRecord.isTLS = true;
if (this.settings.enableTlsDebugLogging) { 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. // Lock the connection to the negotiated SNI.

331
ts/classes.snihandler.ts Normal file
View File

@ -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;
}
}

View File

@ -3,3 +3,4 @@ export * from './classes.networkproxy.js';
export * from './classes.portproxy.js'; export * from './classes.portproxy.js';
export * from './classes.port80handler.js'; export * from './classes.port80handler.js';
export * from './classes.sslredirect.js'; export * from './classes.sslredirect.js';
export * from './classes.snihandler.js';