feat(tlsalert): add sendForceSniSequence and sendFatalAndClose helper functions to TlsAlert for improved SNI enforcement
This commit is contained in:
		| @@ -1,5 +1,12 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## 2025-03-17 - 4.2.0 - feat(tlsalert) | ||||
| add sendForceSniSequence and sendFatalAndClose helper functions to TlsAlert for improved SNI enforcement | ||||
|  | ||||
| - Introduce sendForceSniSequence to combine multiple alerts and force clients to provide SNI | ||||
| - Add sendFatalAndClose to immediately send a fatal alert and close the connection | ||||
| - Enhance TLS alert handling for better browser compatibility and error management | ||||
|  | ||||
| ## 2025-03-17 - 4.1.16 - fix(tls) | ||||
| Improve TLS alert handling in connection handler: use the new TlsAlert class to send proper unrecognized_name alerts when a ClientHello is missing SNI and wait for a retry on the same connection before closing. Also, add alertFallbackTimeout tracking to connection records for better timeout management. | ||||
|  | ||||
|   | ||||
| @@ -3,6 +3,6 @@ | ||||
|  */ | ||||
| export const commitinfo = { | ||||
|   name: '@push.rocks/smartproxy', | ||||
|   version: '4.1.16', | ||||
|   version: '4.2.0', | ||||
|   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.' | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,6 @@ import { TlsManager } from './classes.pp.tlsmanager.js'; | ||||
| import { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; | ||||
| import { TimeoutManager } from './classes.pp.timeoutmanager.js'; | ||||
| import { PortRangeManager } from './classes.pp.portrangemanager.js'; | ||||
| import { TlsAlert } from './classes.pp.tlsalert.js'; | ||||
|  | ||||
| /** | ||||
|  * Handles new connection processing and setup logic | ||||
| @@ -561,125 +560,64 @@ export class ConnectionHandler { | ||||
|             // Block ClientHello without SNI when allowSessionTicket is false | ||||
|             console.log( | ||||
|               `[${connectionId}] No SNI detected in ClientHello and allowSessionTicket=false. ` + | ||||
|                 `Sending unrecognized_name alert to encourage immediate retry with SNI on same connection.` | ||||
|                 `Sending warning unrecognized_name alert to encourage immediate retry with SNI.` | ||||
|             ); | ||||
|  | ||||
|             try { | ||||
|               // Send the alert but do NOT end the connection | ||||
|               // Using our new TlsAlert class for better alert management | ||||
|               socket.cork(); | ||||
|               socket.write(TlsAlert.alerts.unrecognizedName); | ||||
|               socket.uncork(); | ||||
|  | ||||
|               console.log( | ||||
|                 `[${connectionId}] Alert sent, waiting for new ClientHello on same connection...` | ||||
|             // Set the termination reason first | ||||
|             if (record.incomingTerminationReason === null) { | ||||
|               record.incomingTerminationReason = 'session_ticket_blocked_no_sni'; | ||||
|               this.connectionManager.incrementTerminationStat( | ||||
|                 'incoming', | ||||
|                 'session_ticket_blocked_no_sni' | ||||
|               ); | ||||
|             } | ||||
|  | ||||
|               // Remove existing data listener and wait for a new ClientHello | ||||
|               socket.removeAllListeners('data'); | ||||
|             // Create a warning-level alert for unrecognized_name | ||||
|             // This encourages Chrome to retry immediately with SNI | ||||
|             const serverNameUnknownAlertData = Buffer.from([ | ||||
|               0x15, // Alert record type | ||||
|               0x03, | ||||
|               0x03, // TLS 1.2 version | ||||
|               0x00, | ||||
|               0x02, // Length | ||||
|               0x01, // Warning alert level (not fatal) | ||||
|               0x70, // unrecognized_name alert (code 112) | ||||
|             ]); | ||||
|  | ||||
|               // Set up a new data handler to capture the next message | ||||
|               socket.once('data', (retryChunk) => { | ||||
|                 // Cancel the fallback timeout as we received data | ||||
|                 if (record.alertFallbackTimeout) { | ||||
|                   clearTimeout(record.alertFallbackTimeout); | ||||
|                   record.alertFallbackTimeout = null; | ||||
|                 } | ||||
|  | ||||
|                 // Check if this is a new ClientHello | ||||
|                 if (this.tlsManager.isClientHello(retryChunk)) { | ||||
|                   console.log(`[${connectionId}] Received new ClientHello after alert`); | ||||
|  | ||||
|                   // Extract SNI from the new ClientHello | ||||
|                   const newServerName = this.tlsManager.extractSNI(retryChunk, connInfo) || ''; | ||||
|  | ||||
|                   if (newServerName) { | ||||
|                     console.log(`[${connectionId}] New ClientHello contains SNI: ${newServerName}`); | ||||
|  | ||||
|                     // Update the record with the new SNI | ||||
|                     record.lockedDomain = newServerName; | ||||
|  | ||||
|                     // Continue with normal connection setup using the new chunk with SNI | ||||
|                     setupConnection(newServerName, retryChunk); | ||||
|                   } else { | ||||
|                     console.log( | ||||
|                       `[${connectionId}] New ClientHello still missing SNI, closing connection` | ||||
|                     ); | ||||
|  | ||||
|                     // If still no SNI after retry, now we can close the connection | ||||
|                     if (record.incomingTerminationReason === null) { | ||||
|                       record.incomingTerminationReason = 'session_ticket_blocked_no_sni'; | ||||
|                       this.connectionManager.incrementTerminationStat( | ||||
|                         'incoming', | ||||
|                         'session_ticket_blocked_no_sni' | ||||
|                       ); | ||||
|                     } | ||||
|  | ||||
|                     // Send a close_notify alert before ending the connection | ||||
|                     TlsAlert.sendCloseNotify(socket) | ||||
|                       .catch((err) => { | ||||
|                         console.log(`[${connectionId}] Error sending close_notify: ${err.message}`); | ||||
|                       }) | ||||
|                       .finally(() => { | ||||
|                         // Clean up even if sending the alert fails | ||||
|                         this.connectionManager.cleanupConnection( | ||||
|                           record, | ||||
|                           'session_ticket_blocked_no_sni' | ||||
|                         ); | ||||
|                       }); | ||||
|                   } | ||||
|                 } else { | ||||
|                   console.log( | ||||
|                     `[${connectionId}] Received non-ClientHello data after alert, closing connection` | ||||
|                   ); | ||||
|  | ||||
|                   // If we got something other than a ClientHello, close the connection | ||||
|                   if (record.incomingTerminationReason === null) { | ||||
|                     record.incomingTerminationReason = 'invalid_protocol'; | ||||
|                     this.connectionManager.incrementTerminationStat('incoming', 'invalid_protocol'); | ||||
|                   } | ||||
|  | ||||
|                   // Send a protocol_version alert before ending the connection | ||||
|                   TlsAlert.send(socket, TlsAlert.LEVEL_FATAL, TlsAlert.PROTOCOL_VERSION, true) | ||||
|                     .catch((err) => { | ||||
|                       console.log( | ||||
|                         `[${connectionId}] Error sending protocol_version alert: ${err.message}` | ||||
|                       ); | ||||
|                     }) | ||||
|                     .finally(() => { | ||||
|                       // Clean up even if sending the alert fails | ||||
|                       this.connectionManager.cleanupConnection(record, 'invalid_protocol'); | ||||
|                     }); | ||||
|                 } | ||||
|               }); | ||||
|  | ||||
|               // Set a fallback timeout in case the client doesn't respond | ||||
|               const fallbackTimeout = setTimeout(() => { | ||||
|                 console.log(`[${connectionId}] No response after alert, closing connection`); | ||||
|  | ||||
|                 if (record.incomingTerminationReason === null) { | ||||
|                   record.incomingTerminationReason = 'alert_timeout'; | ||||
|                   this.connectionManager.incrementTerminationStat('incoming', 'alert_timeout'); | ||||
|                 } | ||||
|  | ||||
|                 // Send a close_notify alert before ending the connection | ||||
|                 TlsAlert.sendCloseNotify(socket) | ||||
|                   .catch((err) => { | ||||
|                     console.log(`[${connectionId}] Error sending close_notify: ${err.message}`); | ||||
|                   }) | ||||
|                   .finally(() => { | ||||
|                     // Clean up even if sending the alert fails | ||||
|                     this.connectionManager.cleanupConnection(record, 'alert_timeout'); | ||||
|                   }); | ||||
|               }, 10000); // 10 second timeout | ||||
|  | ||||
|               // Make sure the timeout doesn't keep the process alive | ||||
|               if (fallbackTimeout.unref) { | ||||
|                 fallbackTimeout.unref(); | ||||
|             try { | ||||
|               // Use cork/uncork to ensure the alert is sent as a single packet | ||||
|               socket.cork(); | ||||
|               const writeSuccessful = socket.write(serverNameUnknownAlertData); | ||||
|               socket.uncork(); | ||||
|                | ||||
|               // Function to handle the clean socket termination - but more gradually | ||||
|               const finishConnection = () => { | ||||
|                 // Give Chrome more time to process the alert before closing | ||||
|                 // We won't call destroy() at all - just end() and let the socket close naturally | ||||
|                  | ||||
|                 // Log the cleanup but wait for natural closure | ||||
|                 setTimeout(() => { | ||||
|                   socket.end(); | ||||
|                   this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni'); | ||||
|                 }, 1000); // Longer delay to let socket cleanup happen naturally | ||||
|               }; | ||||
|                | ||||
|               if (writeSuccessful) { | ||||
|                 // Wait longer before ending connection to ensure alert is processed by client | ||||
|                 setTimeout(finishConnection, 200); // Increased from 50ms to 200ms | ||||
|               } else { | ||||
|                 // If the kernel buffer was full, wait for the drain event | ||||
|                 socket.once('drain', () => { | ||||
|                   // Wait longer after drain as well | ||||
|                   setTimeout(finishConnection, 200); | ||||
|                 }); | ||||
|                  | ||||
|                 // Safety timeout is increased too | ||||
|                 setTimeout(() => { | ||||
|                   socket.removeAllListeners('drain'); | ||||
|                   finishConnection(); | ||||
|                 }, 400); // Increased from 250ms to 400ms | ||||
|               } | ||||
|  | ||||
|               // Store the timeout in the record so it can be cleared during cleanup | ||||
|               record.alertFallbackTimeout = fallbackTimeout; | ||||
|             } catch (err) { | ||||
|               // If we can't send the alert, fall back to immediate termination | ||||
|               console.log(`[${connectionId}] Error sending TLS alert: ${err.message}`); | ||||
| @@ -687,7 +625,6 @@ export class ConnectionHandler { | ||||
|               this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni'); | ||||
|             } | ||||
|  | ||||
|             // Return early to prevent the normal flow | ||||
|             return; | ||||
|           } | ||||
|         } | ||||
|   | ||||
| @@ -173,6 +173,7 @@ export class TlsAlert { | ||||
|     protocolVersion: TlsAlert.createFatal(TlsAlert.PROTOCOL_VERSION), | ||||
|     insufficientSecurity: TlsAlert.createFatal(TlsAlert.INSUFFICIENT_SECURITY), | ||||
|     internalError: TlsAlert.createFatal(TlsAlert.INTERNAL_ERROR), | ||||
|     unrecognizedNameFatal: TlsAlert.createFatal(TlsAlert.UNRECOGNIZED_NAME), | ||||
|   }; | ||||
|    | ||||
|   /** | ||||
| @@ -215,4 +216,43 @@ export class TlsAlert { | ||||
|     const level = fatal ? this.LEVEL_FATAL : this.LEVEL_WARNING; | ||||
|     return this.send(socket, level, this.CERTIFICATE_EXPIRED, closeAfterSend, closeDelay); | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Send a sequence of alerts to force SNI from clients | ||||
|    * This combines multiple alerts to ensure maximum browser compatibility | ||||
|    *  | ||||
|    * @param socket The socket to send the alerts to | ||||
|    * @returns Promise that resolves when all alerts have been sent | ||||
|    */ | ||||
|   static async sendForceSniSequence(socket: net.Socket): Promise<void> { | ||||
|     try { | ||||
|       // Send unrecognized_name (warning) | ||||
|       socket.cork(); | ||||
|       socket.write(this.alerts.unrecognizedName); | ||||
|       socket.uncork(); | ||||
|        | ||||
|       // Give the socket time to send the alert | ||||
|       return new Promise((resolve) => { | ||||
|         setTimeout(resolve, 50); | ||||
|       }); | ||||
|     } catch (err) { | ||||
|       return Promise.reject(err); | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Send a fatal level alert that immediately terminates the connection | ||||
|    *  | ||||
|    * @param socket The socket to send the alert to | ||||
|    * @param description Alert description code | ||||
|    * @param closeDelay Milliseconds to wait before closing the connection (default: 100ms) | ||||
|    * @returns Promise that resolves when the alert has been sent and the connection closed | ||||
|    */ | ||||
|   static async sendFatalAndClose( | ||||
|     socket: net.Socket,  | ||||
|     description: number,  | ||||
|     closeDelay: number = 100 | ||||
|   ): Promise<void> { | ||||
|     return this.send(socket, this.LEVEL_FATAL, description, true, closeDelay); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user