Skip to content

SSH-over-HTTP-proxy: HttpClientConnector discards bytes that follow the CONNECT response, breaking the handshake when the SSH banner is coalesced with the proxy's 200 reply #270

@t2itto

Description

@t2itto

Version

7.5.0 (org.eclipse.jgit.ssh.apache 7.5.0.202512021534-r). The same code is also present on current master.

Operating System

Windows

Bug description

When tunneling SSH through an HTTP proxy (Proxy.Type.HTTP), HttpClientConnector.messageReceived(...) reads and parses all currently available bytes as the HTTP CONNECT response. Any bytes that follow the terminating \r\n\r\n of the proxy reply — i.e. the SSH server's identification banner (SSH-2.0-...) and the start of its KEX_INIT — are consumed into the HTTP string parsing and never handed back to the SSH transport layer.

If the proxy's HTTP/1.0 200 Connection established\r\n\r\n and the upstream server's banner arrive in the same Readable (the same TCP segment), the banner is lost and the SSH handshake stalls. This is timing-dependent: when the 200 reply and the banner happen to arrive in separate reads it works, which makes the failure intermittent and proxy/network dependent.

Root cause — org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector#messageReceived:

int length = buffer.available();
byte[] data = new byte[length];
buffer.getRawBytes(data, 0, length);              // consumes ALL available bytes
String[] reply = new String(data, US_ASCII).split("\r\n");
handleMessage(session, Arrays.asList(reply));     // on 200 -> setDone(true)

It never stops at the end of the HTTP headers nor re-injects the bytes after them. After setDone(true), AbstractClientProxyConnector clears the proxy handler (session.setProxyHandler(null)) and fires the deferred sendIdentification queued by JGitClientSession#sendIdentification via runWhenDone(...). From then on JGitClientSession#messageReceived would correctly route bytes to ClientSessionImpl#messageReceived — but the server banner that was already pulled out of the same Readable is gone.

How to reproduce:

  1. Configure an SshSessionFactory with an HTTP proxy (ProxyData, Proxy.Type.HTTP) and connect to any SSH/SFTP server through it.
  2. Use (or simulate) a proxy that writes the 200 status/headers and the first relayed upstream bytes in a single TCP segment.

Deterministic harness: a fake CONNECT proxy that, after accepting CONNECT host:port, performs a single write() of "HTTP/1.0 200 Connection established\r\n\r\n" immediately followed by the bytes it just read from the upstream server (the SSH-2.0-... banner). The connector then receives both in one messageReceived call and drops the banner 100% of the time.

Actual behavior

The bytes following the proxy's 200 response (the SSH server's SSH-2.0-... banner / KEX_INIT) are discarded by HttpClientConnector#messageReceived. The SSH transport never receives the server's identification string, so the protocol-version exchange / key exchange never completes and the connect (and subsequent auth) eventually times out.

Expected behavior

HttpClientConnector should consume only up to the end of the HTTP CONNECT response headers (\r\n\r\n) and re-inject any remaining bytes into the SSH layer, so the server banner that was coalesced into the same TCP segment is processed and the handshake proceeds normally.

Relevant log output

Other information

Suggested fix (sketch):

int length = buffer.available();
byte[] data = new byte[length];
buffer.getRawBytes(data, 0, length);

int bodyStart = endOfHeaders(data);                  // index just after "\r\n\r\n", or -1
int headerLen = (bodyStart < 0) ? length : bodyStart;
String[] reply = new String(data, 0, headerLen, US_ASCII).split("\r\n");
handleMessage(session, Arrays.asList(reply));        // on 200 -> setDone(true) clears proxyHandler

if (bodyStart >= 0 && bodyStart < length) {
    byte[] leftover = Arrays.copyOfRange(data, bodyStart, length);
    // proxyHandler is now null, so this is decoded by the normal SSH path
    AbstractSession.getSession(session)
            .messageReceived(new ByteArrayBuffer(leftover));
}

When \r\n\r\n is not yet present (headers split across reads), behavior should stay as it is today.

Open question: Socks5ClientConnector may share the same "consume everything currently in the buffer" pattern and could drop coalesced post-handshake bytes as well — worth auditing alongside this fix.

Environment: reproduced with org.eclipse.jgit.ssh.apache 7.5.0 + Apache MINA sshd 2.15.0 on JDK 21.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions