Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 a8678c8ab1 fix(ssh): keep bytes coalesced with HTTP and SOCKS5 proxy replies
Proxy handshake parsers consumed the whole TCP packet, so an SSH banner
that arrived in the same segment as the HTTP CONNECT or SOCKS5 reply was
never seen by ssh2. Put those trailing bytes back on the socket.

Related to #1414

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:03:23 +00:00
2 changed files with 153 additions and 11 deletions

No files matched your search

+52 -11
View File
@@ -98,6 +98,34 @@ function runWhenProxyConnectionReady(socket, callback) {
callback();
}
// Bytes after the proxy reply (often the SSH banner) can share the reply's
// TCP packet. Put them back for ssh2. Pause first so the tail stays queued
// until the SSH client resumes the socket.
function restoreTrailingSocketBytes(socket, trailing) {
if (!trailing || trailing.length === 0) return;
socket.pause();
socket.unshift(Buffer.from(trailing));
}
// SOCKS5 connect reply: VER REP RSV ATYP BND.ADDR BND.PORT.
// Returns the reply size, null when the buffer is short, or -1 when ATYP is
// not a form we can bound.
function socks5ConnectReplyLength(buffer) {
if (buffer.length < 4) return null;
const addressType = buffer[3];
let addressLength;
if (addressType === 0x01) addressLength = 4;
else if (addressType === 0x04) addressLength = 16;
else if (addressType === 0x03) {
if (buffer.length < 5) return null;
addressLength = 1 + buffer[4];
} else {
return -1;
}
const total = 4 + addressLength + 2;
return buffer.length < total ? null : total;
}
function createProxyCommandSocket(proxy, targetHost, targetPort, options = {}) {
const command = substituteProxyCommand(proxy.command, targetHost, targetPort, { platform: process.platform }).trim();
if (!command) return Promise.reject(new Error("ProxyCommand is required"));
@@ -238,16 +266,18 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
const connectRequest = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\n${authHeader}\r\n`;
socket.write(connectRequest);
let response = '';
let pending = Buffer.alloc(0);
const onData = (data) => {
response += data.toString();
if (response.includes('\r\n\r\n')) {
socket.removeListener('data', onData);
if (response.startsWith('HTTP/1.1 200') || response.startsWith('HTTP/1.0 200')) {
settleResolve();
} else {
settleReject(new Error(`HTTP proxy error: ${response.split('\r\n')[0]}`));
}
pending = pending.length === 0 ? data : Buffer.concat([pending, data]);
const headerEnd = pending.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
socket.removeListener('data', onData);
const header = pending.toString('latin1', 0, headerEnd);
if (header.startsWith('HTTP/1.1 200') || header.startsWith('HTTP/1.0 200')) {
restoreTrailingSocketBytes(socket, pending.subarray(headerEnd + 4));
settleResolve();
} else {
settleReject(new Error(`HTTP proxy error: ${header.split('\r\n')[0]}`));
}
};
socket.on('data', onData);
@@ -265,6 +295,7 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
socket.write(Buffer.from([0x05, authMethods.length, ...authMethods]));
let step = 'greeting';
let connectReply = Buffer.alloc(0);
const onData = (data) => {
if (step === 'greeting') {
if (data[0] !== 0x05) {
@@ -298,8 +329,18 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
step = 'connect';
sendConnectRequest();
} else if (step === 'connect') {
connectReply = connectReply.length === 0
? data
: Buffer.concat([connectReply, data]);
const replyLength = socks5ConnectReplyLength(connectReply);
if (replyLength === null) return;
socket.removeListener('data', onData);
if (data[1] === 0x00) {
if (replyLength < 0) {
settleReject(new Error('Invalid SOCKS5 connect reply'));
return;
}
if (connectReply[1] === 0x00) {
restoreTrailingSocketBytes(socket, connectReply.subarray(replyLength));
settleResolve();
} else {
const errors = {
@@ -312,7 +353,7 @@ function createProxySocket(proxy, targetHost, targetPort, options = {}) {
0x07: 'Command not supported',
0x08: 'Address type not supported',
};
settleReject(new Error(`SOCKS5 error: ${errors[data[1]] || 'Unknown'}`));
settleReject(new Error(`SOCKS5 error: ${errors[connectReply[1]] || 'Unknown'}`));
}
}
};
+101
View File
@@ -3,6 +3,7 @@ const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { once } = require("node:events");
const net = require("node:net");
const { Client } = require("ssh2");
const {
createProxySocket,
@@ -10,6 +11,52 @@ const {
substituteProxyCommand,
} = require("./proxyUtils.cjs");
const SSH_BANNER = Buffer.from("SSH-2.0-Fixture\r\n");
function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise((resolve) => server.close(resolve));
}
// ssh2 only records the peer banner after it parses an identification line.
// "Connection lost before handshake" means those bytes never reached it.
function remoteIdent(socket) {
return new Promise((resolve, reject) => {
const client = new Client();
let settled = false;
const finish = (error, ident) => {
if (settled) return;
settled = true;
clearTimeout(timer);
setImmediate(() => {
try { client.destroy(); } catch { /* ignore */ }
});
if (error) reject(error);
else resolve(ident);
};
const timer = setTimeout(() => finish(new Error("Timed out waiting for remote SSH ident")), 1500);
timer.unref();
client.on("error", (err) => {
if (/Connection lost before handshake/.test(err.message)) finish(err);
});
client.connect({
sock: socket,
username: "fixture",
readyTimeout: 1500,
debug(message) {
const match = /Remote ident: (.*)/.exec(message);
if (match) finish(null, match[1]);
},
});
});
}
test("substituteProxyCommand replaces OpenSSH-style host and port tokens for POSIX shells", () => {
assert.equal(
substituteProxyCommand(
@@ -183,3 +230,57 @@ test("createProxySocket times out stalled HTTP proxy handshakes", async (t) => {
);
assert.equal(socketDestroyed, true);
});
test("HTTP CONNECT keeps an SSH banner coalesced with the proxy reply", async (t) => {
const sockets = new Set();
const proxy = net.createServer((socket) => {
sockets.add(socket);
socket.on("error", () => {});
socket.once("data", () => {
socket.write(Buffer.concat([
Buffer.from("HTTP/1.1 200 Connection Established\r\n\r\n"),
SSH_BANNER,
]));
});
});
t.after(async () => {
for (const socket of sockets) socket.destroy();
await closeServer(proxy);
});
const port = await listen(proxy);
const socket = await createProxySocket(
{ type: "http", host: "127.0.0.1", port },
"203.0.113.10",
22,
);
sockets.add(socket);
assert.match(await remoteIdent(socket), /SSH-2\.0-Fixture/);
});
test("SOCKS5 keeps an SSH banner coalesced with the connect reply", async (t) => {
const sockets = new Set();
const proxy = net.createServer((socket) => {
sockets.add(socket);
socket.on("error", () => {});
socket.once("data", () => {
socket.write(Buffer.from([0x05, 0x00]));
socket.once("data", () => {
// IPv4 bind address is 10 bytes; the banner shares this write.
const reply = Buffer.from([0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 22]);
socket.write(Buffer.concat([reply, SSH_BANNER]));
});
});
});
t.after(async () => {
for (const socket of sockets) socket.destroy();
await closeServer(proxy);
});
const port = await listen(proxy);
const socket = await createProxySocket(
{ type: "socks5", host: "127.0.0.1", port },
"203.0.113.10",
22,
);
sockets.add(socket);
assert.match(await remoteIdent(socket), /SSH-2\.0-Fixture/);
});