feat(tlsalert): add sendForceSniSequence and sendFatalAndClose helper functions to TlsAlert for improved SNI enforcement
This commit is contained in:
		| @@ -1,5 +1,12 @@ | |||||||
| # Changelog | # 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) | ## 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. | 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 = { | export const commitinfo = { | ||||||
|   name: '@push.rocks/smartproxy', |   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.' |   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 { NetworkProxyBridge } from './classes.pp.networkproxybridge.js'; | ||||||
| import { TimeoutManager } from './classes.pp.timeoutmanager.js'; | import { TimeoutManager } from './classes.pp.timeoutmanager.js'; | ||||||
| import { PortRangeManager } from './classes.pp.portrangemanager.js'; | import { PortRangeManager } from './classes.pp.portrangemanager.js'; | ||||||
| import { TlsAlert } from './classes.pp.tlsalert.js'; |  | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Handles new connection processing and setup logic |  * Handles new connection processing and setup logic | ||||||
| @@ -561,52 +560,10 @@ export class ConnectionHandler { | |||||||
|             // Block ClientHello without SNI when allowSessionTicket is false |             // Block ClientHello without SNI when allowSessionTicket is false | ||||||
|             console.log( |             console.log( | ||||||
|               `[${connectionId}] No SNI detected in ClientHello and allowSessionTicket=false. ` + |               `[${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 { |             // Set the termination reason first | ||||||
|               // 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...` |  | ||||||
|               ); |  | ||||||
|  |  | ||||||
|               // Remove existing data listener and wait for a new ClientHello |  | ||||||
|               socket.removeAllListeners('data'); |  | ||||||
|  |  | ||||||
|               // 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) { |             if (record.incomingTerminationReason === null) { | ||||||
|               record.incomingTerminationReason = 'session_ticket_blocked_no_sni'; |               record.incomingTerminationReason = 'session_ticket_blocked_no_sni'; | ||||||
|               this.connectionManager.incrementTerminationStat( |               this.connectionManager.incrementTerminationStat( | ||||||
| @@ -615,71 +572,52 @@ export class ConnectionHandler { | |||||||
|               ); |               ); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|                     // Send a close_notify alert before ending the connection |             // Create a warning-level alert for unrecognized_name | ||||||
|                     TlsAlert.sendCloseNotify(socket) |             // This encourages Chrome to retry immediately with SNI | ||||||
|                       .catch((err) => { |             const serverNameUnknownAlertData = Buffer.from([ | ||||||
|                         console.log(`[${connectionId}] Error sending close_notify: ${err.message}`); |               0x15, // Alert record type | ||||||
|                       }) |               0x03, | ||||||
|                       .finally(() => { |               0x03, // TLS 1.2 version | ||||||
|                         // Clean up even if sending the alert fails |               0x00, | ||||||
|                         this.connectionManager.cleanupConnection( |               0x02, // Length | ||||||
|                           record, |               0x01, // Warning alert level (not fatal) | ||||||
|                           'session_ticket_blocked_no_sni' |               0x70, // unrecognized_name alert (code 112) | ||||||
|                         ); |             ]); | ||||||
|                       }); |  | ||||||
|                   } |             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 { |               } else { | ||||||
|                   console.log( |                 // If the kernel buffer was full, wait for the drain event | ||||||
|                     `[${connectionId}] Received non-ClientHello data after alert, closing connection` |                 socket.once('drain', () => { | ||||||
|                   ); |                   // Wait longer after drain as well | ||||||
|  |                   setTimeout(finishConnection, 200); | ||||||
|                   // 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 |                 // Safety timeout is increased too | ||||||
|               const fallbackTimeout = setTimeout(() => { |                 setTimeout(() => { | ||||||
|                 console.log(`[${connectionId}] No response after alert, closing connection`); |                   socket.removeAllListeners('drain'); | ||||||
|  |                   finishConnection(); | ||||||
|                 if (record.incomingTerminationReason === null) { |                 }, 400); // Increased from 250ms to 400ms | ||||||
|                   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(); |  | ||||||
|               } |  | ||||||
|  |  | ||||||
|               // Store the timeout in the record so it can be cleared during cleanup |  | ||||||
|               record.alertFallbackTimeout = fallbackTimeout; |  | ||||||
|             } catch (err) { |             } catch (err) { | ||||||
|               // If we can't send the alert, fall back to immediate termination |               // If we can't send the alert, fall back to immediate termination | ||||||
|               console.log(`[${connectionId}] Error sending TLS alert: ${err.message}`); |               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'); |               this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni'); | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Return early to prevent the normal flow |  | ||||||
|             return; |             return; | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -173,6 +173,7 @@ export class TlsAlert { | |||||||
|     protocolVersion: TlsAlert.createFatal(TlsAlert.PROTOCOL_VERSION), |     protocolVersion: TlsAlert.createFatal(TlsAlert.PROTOCOL_VERSION), | ||||||
|     insufficientSecurity: TlsAlert.createFatal(TlsAlert.INSUFFICIENT_SECURITY), |     insufficientSecurity: TlsAlert.createFatal(TlsAlert.INSUFFICIENT_SECURITY), | ||||||
|     internalError: TlsAlert.createFatal(TlsAlert.INTERNAL_ERROR), |     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; |     const level = fatal ? this.LEVEL_FATAL : this.LEVEL_WARNING; | ||||||
|     return this.send(socket, level, this.CERTIFICATE_EXPIRED, closeAfterSend, closeDelay); |     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