Compare commits

...

6 Commits

Author SHA1 Message Date
efbb4335d7 4.1.5
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 59s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-16 13:19:37 +00:00
9dd402054d fix(TLS/ConnectionHandler): Improve handling of TLS session resumption without SNI by sending an unrecognized_name alert instead of immediately terminating the connection. This change adds a grace period for the client to retry the handshake with proper SNI and cleans up the connection if no valid response is received. 2025-03-16 13:19:37 +00:00
6c1efc1dc0 4.1.4
Some checks failed
Default (tags) / security (push) Successful in 29s
Default (tags) / test (push) Failing after 1m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-15 19:10:54 +00:00
cad0e6a2b2 fix(ConnectionHandler): Refactor ConnectionHandler code formatting for improved readability and consistency in log messages and whitespace handling 2025-03-15 19:10:54 +00:00
794e1292e5 4.1.3
Some checks failed
Default (tags) / security (push) Successful in 38s
Default (tags) / test (push) Failing after 1m0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-03-15 18:51:50 +00:00
ee79f9ab7c fix(connectionhandler): Improve handling of TLS ClientHello messages when allowSessionTicket is disabled and no SNI is provided by sending a warning alert (unrecognized_name, code 0x70) with a proper callback and delay to ensure the alert is transmitted before closing the connection. 2025-03-15 18:51:50 +00:00
4 changed files with 251 additions and 162 deletions

View File

@ -1,5 +1,27 @@
# Changelog # Changelog
## 2025-03-16 - 4.1.5 - fix(TLS/ConnectionHandler)
Improve handling of TLS session resumption without SNI by sending an 'unrecognized_name' alert instead of immediately terminating the connection. This change adds a grace period for the client to retry the handshake with proper SNI and cleans up the connection if no valid response is received.
- Send a TLS warning (unrecognized_name alert, code 112) when a ClientHello is received without SNI and session tickets are disallowed.
- Utilize socket cork/uncork to ensure the alert is sent as a single packet.
- Add a 5-second alert timeout and a subsequent 30-second grace period to allow clients to initiate a new handshake with SNI.
- Clean up and terminate the connection if no valid SNI is provided after the grace period, logging appropriate termination reasons.
## 2025-03-15 - 4.1.4 - fix(ConnectionHandler)
Refactor ConnectionHandler code formatting for improved readability and consistency in log messages and whitespace handling
- Standardized indentation and spacing in method signatures and log statements
- Aligned inline comments and string concatenations for clarity
- Minor refactoring of parameter formatting without changing functionality
## 2025-03-15 - 4.1.3 - fix(connectionhandler)
Improve handling of TLS ClientHello messages when allowSessionTicket is disabled and no SNI is provided by sending a warning alert (unrecognized_name, code 0x70) with a proper callback and delay to ensure the alert is transmitted before closing the connection.
- Replace the fatal alert (0x02/0x40) with a warning alert (0x01/0x70) to notify clients to send SNI.
- Use socket.write callback to wait 100ms after sending the alert before terminating the connection.
- Remove the previous short (50ms) delay in favor of a more reliable delay mechanism before cleanup.
## 2025-03-15 - 4.1.2 - fix(connectionhandler) ## 2025-03-15 - 4.1.2 - fix(connectionhandler)
Send proper TLS alert before terminating connections when SNI is missing and session tickets are disallowed. Send proper TLS alert before terminating connections when SNI is missing and session tickets are disallowed.

View File

@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartproxy", "name": "@push.rocks/smartproxy",
"version": "4.1.2", "version": "4.1.5",
"private": false, "private": false,
"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.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",

View File

@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartproxy', name: '@push.rocks/smartproxy',
version: '4.1.2', version: '4.1.5',
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,9 @@
import * as plugins from './plugins.js'; import * as plugins from './plugins.js';
import type { IConnectionRecord, IDomainConfig, IPortProxySettings } from './classes.pp.interfaces.js'; import type {
IConnectionRecord,
IDomainConfig,
IPortProxySettings,
} from './classes.pp.interfaces.js';
import { ConnectionManager } from './classes.pp.connectionmanager.js'; import { ConnectionManager } from './classes.pp.connectionmanager.js';
import { SecurityManager } from './classes.pp.securitymanager.js'; import { SecurityManager } from './classes.pp.securitymanager.js';
import { DomainConfigManager } from './classes.pp.domainconfigmanager.js'; import { DomainConfigManager } from './classes.pp.domainconfigmanager.js';
@ -94,7 +98,10 @@ export class ConnectionHandler {
/** /**
* Handle a connection that should be forwarded to NetworkProxy * Handle a connection that should be forwarded to NetworkProxy
*/ */
private handleNetworkProxyConnection(socket: plugins.net.Socket, record: IConnectionRecord): void { private handleNetworkProxyConnection(
socket: plugins.net.Socket,
record: IConnectionRecord
): void {
const connectionId = record.id; const connectionId = record.id;
let initialDataReceived = false; let initialDataReceived = false;
@ -159,8 +166,8 @@ export class ConnectionHandler {
if (this.tlsManager.isTlsHandshake(chunk)) { if (this.tlsManager.isTlsHandshake(chunk)) {
record.isTLS = true; record.isTLS = true;
// Check session tickets if they're disabled // Check for ClientHello to extract SNI - but don't enforce it for NetworkProxy
if (this.settings.allowSessionTicket === false && this.tlsManager.isClientHello(chunk)) { if (this.tlsManager.isClientHello(chunk)) {
// Create connection info for SNI extraction // Create connection info for SNI extraction
const connInfo = { const connInfo = {
sourceIp: record.remoteIP, sourceIp: record.remoteIP,
@ -169,48 +176,12 @@ export class ConnectionHandler {
destPort: socket.localPort || 0, destPort: socket.localPort || 0,
}; };
// Extract SNI for domain-specific NetworkProxy handling // Extract SNI for domain-specific NetworkProxy handling if available
const serverName = this.tlsManager.extractSNI(chunk, connInfo); const serverName = this.tlsManager.extractSNI(chunk, connInfo);
// If allowSessionTicket is false and we can't determine SNI, terminate the connection // For NetworkProxy connections, we'll allow session tickets even without SNI
if (!serverName) { // We'll only use the serverName if available to determine the specific NetworkProxy port
// Always block when allowSessionTicket is false and there's no SNI if (serverName) {
// Don't even check for session resumption - be strict
console.log(
`[${connectionId}] No SNI detected in ClientHello and allowSessionTicket=false. ` +
`Terminating connection to force new TLS handshake with SNI.`
);
// Send a proper TLS alert before ending the connection
// This helps browsers like Chrome properly recognize the error
const alertData = Buffer.from([
0x15, // Alert record type
0x03, 0x03, // TLS 1.2 version
0x00, 0x02, // Length
0x02, // Fatal alert level
0x40 // Handshake failure alert
]);
try {
socket.write(alertData);
} catch (err) {
// Ignore write errors, we're closing anyway
}
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
this.connectionManager.incrementTerminationStat('incoming', 'session_ticket_blocked_no_sni');
}
// Add a small delay before ending to allow alert to be sent
setTimeout(() => {
socket.end();
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
}, 50);
return;
}
// Save domain config and SNI in connection record // Save domain config and SNI in connection record
const domainConfig = this.domainConfigManager.findDomainConfig(serverName); const domainConfig = this.domainConfigManager.findDomainConfig(serverName);
record.domainConfig = domainConfig; record.domainConfig = domainConfig;
@ -237,6 +208,15 @@ export class ConnectionHandler {
); );
return; return;
} }
} else if (
this.settings.allowSessionTicket === false &&
this.settings.enableDetailedLogging
) {
// Log that we're allowing a session resumption without SNI for NetworkProxy
console.log(
`[${connectionId}] Allowing session resumption without SNI for NetworkProxy forwarding`
);
}
} }
// Forward directly to NetworkProxy without domain-specific settings // Forward directly to NetworkProxy without domain-specific settings
@ -250,14 +230,10 @@ export class ConnectionHandler {
); );
} else { } else {
// If not TLS, use normal direct connection // If not TLS, use normal direct connection
console.log(`[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`); console.log(
this.setupDirectConnection( `[${connectionId}] Non-TLS connection on NetworkProxy port ${record.localPort}`
socket,
record,
undefined,
undefined,
chunk
); );
this.setupDirectConnection(socket, record, undefined, undefined, chunk);
} }
}); });
} }
@ -375,14 +351,13 @@ export class ConnectionHandler {
record.domainConfig = domainConfig; record.domainConfig = domainConfig;
// Check if this domain should use NetworkProxy (domain-specific setting) // Check if this domain should use NetworkProxy (domain-specific setting)
if (domainConfig && if (
domainConfig &&
this.domainConfigManager.shouldUseNetworkProxy(domainConfig) && this.domainConfigManager.shouldUseNetworkProxy(domainConfig) &&
this.networkProxyBridge.getNetworkProxy()) { this.networkProxyBridge.getNetworkProxy()
) {
if (this.settings.enableDetailedLogging) { if (this.settings.enableDetailedLogging) {
console.log( console.log(`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`);
`[${connectionId}] Domain ${serverName} is configured to use NetworkProxy`
);
} }
const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig); const networkProxyPort = this.domainConfigManager.getNetworkProxyPort(domainConfig);
@ -408,19 +383,20 @@ export class ConnectionHandler {
// Skip IP validation if allowedIPs is empty // Skip IP validation if allowedIPs is empty
if ( if (
domainConfig.allowedIPs.length > 0 && domainConfig.allowedIPs.length > 0 &&
!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs) !this.securityManager.isIPAuthorized(
record.remoteIP,
ipRules.allowedIPs,
ipRules.blockedIPs
)
) { ) {
return rejectIncomingConnection( return rejectIncomingConnection(
'rejected', 'rejected',
`Connection rejected: IP ${record.remoteIP} not allowed for domain ${domainConfig.domains.join( `Connection rejected: IP ${
', ' record.remoteIP
)}` } not allowed for domain ${domainConfig.domains.join(', ')}`
); );
} }
} else if ( } else if (this.settings.defaultAllowedIPs && this.settings.defaultAllowedIPs.length > 0) {
this.settings.defaultAllowedIPs &&
this.settings.defaultAllowedIPs.length > 0
) {
if ( if (
!this.securityManager.isIPAuthorized( !this.securityManager.isIPAuthorized(
record.remoteIP, record.remoteIP,
@ -491,9 +467,17 @@ export class ConnectionHandler {
if (forcedDomain) { if (forcedDomain) {
const ipRules = this.domainConfigManager.getEffectiveIPRules(forcedDomain); const ipRules = this.domainConfigManager.getEffectiveIPRules(forcedDomain);
if (!this.securityManager.isIPAuthorized(record.remoteIP, ipRules.allowedIPs, ipRules.blockedIPs)) { if (
!this.securityManager.isIPAuthorized(
record.remoteIP,
ipRules.allowedIPs,
ipRules.blockedIPs
)
) {
console.log( console.log(
`[${connectionId}] Connection from ${record.remoteIP} rejected: IP not allowed for domain ${forcedDomain.domains.join( `[${connectionId}] Connection from ${
record.remoteIP
} rejected: IP not allowed for domain ${forcedDomain.domains.join(
', ' ', '
)} on port ${localPort}.` )} on port ${localPort}.`
); );
@ -503,9 +487,9 @@ export class ConnectionHandler {
if (this.settings.enableDetailedLogging) { if (this.settings.enableDetailedLogging) {
console.log( console.log(
`[${connectionId}] Port-based connection from ${record.remoteIP} on port ${localPort} matched domain ${forcedDomain.domains.join( `[${connectionId}] Port-based connection from ${
', ' record.remoteIP
)}.` } on port ${localPort} matched domain ${forcedDomain.domains.join(', ')}.`
); );
} }
@ -568,42 +552,128 @@ export class ConnectionHandler {
serverName = this.tlsManager.extractSNI(chunk, connInfo) || ''; serverName = this.tlsManager.extractSNI(chunk, connInfo) || '';
// If allowSessionTicket is false and this is a ClientHello with no SNI, terminate the connection // If allowSessionTicket is false and this is a ClientHello with no SNI, terminate the connection
if (this.settings.allowSessionTicket === false && if (
this.settings.allowSessionTicket === false &&
this.tlsManager.isClientHello(chunk) && this.tlsManager.isClientHello(chunk) &&
!serverName) { !serverName
) {
// Always block ClientHello without SNI when allowSessionTicket is false // Always block ClientHello without SNI when allowSessionTicket is false
// Don't even check for session resumption - be strict
console.log( console.log(
`[${connectionId}] No SNI detected in ClientHello and allowSessionTicket=false. ` + `[${connectionId}] No SNI detected in ClientHello and allowSessionTicket=false. ` +
`Terminating connection to force new TLS handshake with SNI.` `Sending unrecognized_name alert to encourage client to retry with SNI.`
); );
// Set the termination reason first to avoid races
if (record.incomingTerminationReason === null) {
record.incomingTerminationReason = 'session_ticket_blocked_no_sni';
this.connectionManager.incrementTerminationStat(
'incoming',
'session_ticket_blocked_no_sni'
);
}
// Send a proper TLS alert before ending the connection // Send a proper TLS alert before ending the connection
// Using "unrecognized_name" (112) alert which is a warning level alert (1)
// that encourages clients to retry with proper SNI
const alertData = Buffer.from([ const alertData = Buffer.from([
0x15, // Alert record type 0x15, // Alert record type
0x03, 0x03, // TLS 1.2 version 0x03,
0x00, 0x02, // Length 0x03, // TLS 1.2 version
0x02, // Fatal alert level 0x00,
0x40 // Handshake failure alert 0x02, // Length
0x01, // Warning alert level (not fatal)
0x70, // unrecognized_name alert (code 112)
]); ]);
try { try {
// Make sure the alert is sent as a single packet
socket.cork();
socket.write(alertData); socket.write(alertData);
} catch (err) { socket.uncork();
// Ignore write errors, we're closing anyway
}
if (record.incomingTerminationReason === null) { // Set a longer timeout to allow the client to properly handle the alert
record.incomingTerminationReason = 'session_ticket_blocked_no_sni'; // The client might respond by closing the connection or initiating a new handshake
this.connectionManager.incrementTerminationStat('incoming', 'session_ticket_blocked_no_sni'); const alertTimeout = setTimeout(() => {
} if (!socket.destroyed) {
console.log(`[${connectionId}] Client didn't respond to TLS alert, closing connection gracefully.`);
// Add a small delay before ending to allow alert to be sent // Gracefully end the connection
socket.end();
// Only destroy after a delay if it's still hanging
setTimeout(() => { setTimeout(() => {
if (!socket.destroyed) {
console.log(`[${connectionId}] Forcibly closing connection that didn't terminate properly.`);
socket.destroy();
}
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
}, 2000);
}
}, 5000); // Give the client 5 seconds to respond to our alert
// Don't let this timeout keep the process alive
if (alertTimeout.unref) {
alertTimeout.unref();
}
// Handle a proper close from the client
socket.once('close', () => {
clearTimeout(alertTimeout);
console.log(`[${connectionId}] Client closed connection after receiving TLS alert.`);
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni_client_closed');
});
// Also handle if the client sends more data (possibly a new handshake with SNI)
socket.once('data', (newChunk) => {
clearTimeout(alertTimeout);
console.log(`[${connectionId}] Client sent new data after TLS alert, checking for SNI...`);
// This would normally be handled by our renegotiation handler,
// but since we're in a special case, we'll check for SNI again
if (this.tlsManager.isTlsHandshake(newChunk) && this.tlsManager.isClientHello(newChunk)) {
const newServerName = this.tlsManager.extractSNI(newChunk, connInfo);
if (newServerName) {
console.log(`[${connectionId}] Client provided SNI in new handshake: ${newServerName}`);
// Update the record
record.incomingTerminationReason = null;
// Remove termination stats increment
// Note: This is a little hacky as we don't have a proper way to decrement stats
// Process the new handshake with SNI
record.lockedDomain = newServerName;
setupConnection(newServerName, newChunk);
return;
} else {
console.log(`[${connectionId}] Client sent new handshake but still without SNI, closing connection.`);
socket.end();
setTimeout(() => {
if (!socket.destroyed) {
socket.destroy();
}
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni_retry_failed');
}, 500);
}
} else {
console.log(`[${connectionId}] Client sent non-handshake data after TLS alert, closing connection.`);
socket.end();
setTimeout(() => {
if (!socket.destroyed) {
socket.destroy();
}
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_invalid_response');
}, 500);
}
});
} catch (err) {
// If we can't send the alert, fall back to immediate termination
console.log(`[${connectionId}] Error sending TLS alert: ${err.message}`);
socket.end(); socket.end();
this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni'); this.connectionManager.cleanupConnection(record, 'session_ticket_blocked_no_sni');
}, 50); }
return; return;
} }
@ -660,9 +730,7 @@ export class ConnectionHandler {
: this.settings.targetIP!; : this.settings.targetIP!;
// Determine target port // Determine target port
const targetPort = overridePort !== undefined const targetPort = overridePort !== undefined ? overridePort : this.settings.toPort;
? overridePort
: this.settings.toPort;
// Setup connection options // Setup connection options
const connectionOptions: plugins.net.NetConnectOpts = { const connectionOptions: plugins.net.NetConnectOpts = {
@ -935,7 +1003,9 @@ export class ConnectionHandler {
const combinedData = Buffer.concat(record.pendingData); const combinedData = Buffer.concat(record.pendingData);
if (this.settings.enableDetailedLogging) { if (this.settings.enableDetailedLogging) {
console.log(`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`); console.log(
`[${connectionId}] Forwarding ${combinedData.length} bytes of initial data to target`
);
} }
// Write pending data immediately // Write pending data immediately
@ -1033,15 +1103,12 @@ export class ConnectionHandler {
} }
// Set connection timeout // Set connection timeout
record.cleanupTimer = this.timeoutManager.setupConnectionTimeout( record.cleanupTimer = this.timeoutManager.setupConnectionTimeout(record, (record, reason) => {
record,
(record, reason) => {
console.log( console.log(
`[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.` `[${connectionId}] Connection from ${record.remoteIP} exceeded max lifetime, forcing cleanup.`
); );
this.connectionManager.initiateCleanupOnce(record, reason); this.connectionManager.initiateCleanupOnce(record, reason);
} });
);
// Mark TLS handshake as complete for TLS connections // Mark TLS handshake as complete for TLS connections
if (record.isTLS) { if (record.isTLS) {