| /* |
| * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> |
| * and other copyright owners as documented in the project's IP log. |
| * |
| * This program and the accompanying materials are made available |
| * under the terms of the Eclipse Distribution License v1.0 which |
| * accompanies this distribution, is reproduced below, and is |
| * available at http://www.eclipse.org/org/documents/edl-v10.php |
| * |
| * All rights reserved. |
| * |
| * Redistribution and use in source and binary forms, with or |
| * without modification, are permitted provided that the following |
| * conditions are met: |
| * |
| * - Redistributions of source code must retain the above copyright |
| * notice, this list of conditions and the following disclaimer. |
| * |
| * - Redistributions in binary form must reproduce the above |
| * copyright notice, this list of conditions and the following |
| * disclaimer in the documentation and/or other materials provided |
| * with the distribution. |
| * |
| * - Neither the name of the Eclipse Foundation, Inc. nor the |
| * names of its contributors may be used to endorse or promote |
| * products derived from this software without specific prior |
| * written permission. |
| * |
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND |
| * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, |
| * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES |
| * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE |
| * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR |
| * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, |
| * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT |
| * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
| * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER |
| * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, |
| * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) |
| * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF |
| * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| */ |
| package org.eclipse.jgit.internal.transport.sshd.proxy; |
| |
| import static java.nio.charset.StandardCharsets.US_ASCII; |
| import static java.nio.charset.StandardCharsets.UTF_8; |
| import static java.text.MessageFormat.format; |
| |
| import java.io.IOException; |
| import java.net.InetAddress; |
| import java.net.InetSocketAddress; |
| |
| import org.apache.sshd.client.session.ClientSession; |
| import org.apache.sshd.common.io.IoSession; |
| import org.apache.sshd.common.util.Readable; |
| import org.apache.sshd.common.util.buffer.Buffer; |
| import org.apache.sshd.common.util.buffer.BufferUtils; |
| import org.apache.sshd.common.util.buffer.ByteArrayBuffer; |
| import org.eclipse.jgit.annotations.NonNull; |
| import org.eclipse.jgit.internal.transport.sshd.GssApiMechanisms; |
| import org.eclipse.jgit.internal.transport.sshd.SshdText; |
| import org.eclipse.jgit.internal.transport.sshd.auth.AuthenticationHandler; |
| import org.eclipse.jgit.internal.transport.sshd.auth.BasicAuthentication; |
| import org.eclipse.jgit.internal.transport.sshd.auth.GssApiAuthentication; |
| import org.eclipse.jgit.transport.SshConstants; |
| import org.ietf.jgss.GSSContext; |
| |
| /** |
| * A {@link AbstractClientProxyConnector} to connect through a SOCKS5 proxy. |
| * |
| * @see <a href="https://tools.ietf.org/html/rfc1928">RFC 1928</a> |
| */ |
| public class Socks5ClientConnector extends AbstractClientProxyConnector { |
| |
| // private static final byte SOCKS_VERSION_4 = 4; |
| private static final byte SOCKS_VERSION_5 = 5; |
| |
| private static final byte SOCKS_CMD_CONNECT = 1; |
| // private static final byte SOCKS5_CMD_BIND = 2; |
| // private static final byte SOCKS5_CMD_UDP_ASSOCIATE = 3; |
| |
| // Address types |
| |
| private static final byte SOCKS_ADDRESS_IPv4 = 1; |
| |
| private static final byte SOCKS_ADDRESS_FQDN = 3; |
| |
| private static final byte SOCKS_ADDRESS_IPv6 = 4; |
| |
| // Reply codes |
| |
| private static final byte SOCKS_REPLY_SUCCESS = 0; |
| |
| private static final byte SOCKS_REPLY_FAILURE = 1; |
| |
| private static final byte SOCKS_REPLY_FORBIDDEN = 2; |
| |
| private static final byte SOCKS_REPLY_NETWORK_UNREACHABLE = 3; |
| |
| private static final byte SOCKS_REPLY_HOST_UNREACHABLE = 4; |
| |
| private static final byte SOCKS_REPLY_CONNECTION_REFUSED = 5; |
| |
| private static final byte SOCKS_REPLY_TTL_EXPIRED = 6; |
| |
| private static final byte SOCKS_REPLY_COMMAND_UNSUPPORTED = 7; |
| |
| private static final byte SOCKS_REPLY_ADDRESS_UNSUPPORTED = 8; |
| |
| /** |
| * Authentication methods for SOCKS5. |
| * |
| * @see <a href= |
| * "https://www.iana.org/assignments/socks-methods/socks-methods.xhtml">SOCKS |
| * Methods, IANA.org</a> |
| */ |
| private enum SocksAuthenticationMethod { |
| |
| ANONYMOUS(0), |
| GSSAPI(1), |
| PASSWORD(2), |
| // CHALLENGE_HANDSHAKE(3), |
| // CHALLENGE_RESPONSE(5), |
| // SSL(6), |
| // NDS(7), |
| // MULTI_AUTH(8), |
| // JSON(9), |
| NONE_ACCEPTABLE(0xFF); |
| |
| private byte value; |
| |
| SocksAuthenticationMethod(int value) { |
| this.value = (byte) value; |
| } |
| |
| public byte getValue() { |
| return value; |
| } |
| } |
| |
| private enum ProtocolState { |
| NONE, |
| |
| INIT { |
| @Override |
| public void handleMessage(Socks5ClientConnector connector, |
| IoSession session, Buffer data) throws Exception { |
| connector.versionCheck(data.getByte()); |
| SocksAuthenticationMethod authMethod = connector.getAuthMethod( |
| data.getByte()); |
| switch (authMethod) { |
| case ANONYMOUS: |
| connector.sendConnectInfo(session); |
| break; |
| case PASSWORD: |
| connector.doPasswordAuth(session); |
| break; |
| case GSSAPI: |
| connector.doGssApiAuth(session); |
| break; |
| default: |
| throw new IOException( |
| format(SshdText.get().proxyCannotAuthenticate, |
| connector.proxyAddress)); |
| } |
| } |
| }, |
| |
| AUTHENTICATING { |
| @Override |
| public void handleMessage(Socks5ClientConnector connector, |
| IoSession session, Buffer data) throws Exception { |
| connector.authStep(session, data); |
| } |
| }, |
| |
| CONNECTING { |
| @Override |
| public void handleMessage(Socks5ClientConnector connector, |
| IoSession session, Buffer data) throws Exception { |
| // Special case: when GSS-API authentication completes, the |
| // client moves into CONNECTING as soon as the GSS context is |
| // established and sends the connect request. This is per RFC |
| // 1961. But for the server, RFC 1961 says it _should_ send an |
| // empty token even if none generated when its server side |
| // context is established. That means we may actually get an |
| // empty token here. That message is 4 bytes long (and has |
| // content 0x01, 0x01, 0x00, 0x00). We simply skip this message |
| // if we get it here. If the server for whatever reason sends |
| // back a "GSS failed" message (it shouldn't, at this point) |
| // it will be two bytes 0x01 0xFF, which will fail the version |
| // check. |
| if (data.available() != 4) { |
| connector.versionCheck(data.getByte()); |
| connector.establishConnection(data); |
| } |
| } |
| }, |
| |
| CONNECTED, |
| |
| FAILED; |
| |
| public void handleMessage(Socks5ClientConnector connector, |
| @SuppressWarnings("unused") IoSession session, Buffer data) |
| throws Exception { |
| throw new IOException( |
| format(SshdText.get().proxySocksUnexpectedMessage, |
| connector.proxyAddress, this, |
| BufferUtils.toHex(data.array()))); |
| } |
| } |
| |
| private ProtocolState state; |
| |
| private AuthenticationHandler<Buffer, Buffer> authenticator; |
| |
| private GSSContext context; |
| |
| private byte[] authenticationProposals; |
| |
| /** |
| * Creates a new {@link Socks5ClientConnector}. The connector supports |
| * anonymous connections as well as username-password or Kerberos5 (GSS-API) |
| * authentication. |
| * |
| * @param proxyAddress |
| * of the proxy server we're connecting to |
| * @param remoteAddress |
| * of the target server to connect to |
| */ |
| public Socks5ClientConnector(@NonNull InetSocketAddress proxyAddress, |
| @NonNull InetSocketAddress remoteAddress) { |
| this(proxyAddress, remoteAddress, null, null); |
| } |
| |
| /** |
| * Creates a new {@link Socks5ClientConnector}. The connector supports |
| * anonymous connections as well as username-password or Kerberos5 (GSS-API) |
| * authentication. |
| * |
| * @param proxyAddress |
| * of the proxy server we're connecting to |
| * @param remoteAddress |
| * of the target server to connect to |
| * @param proxyUser |
| * to authenticate at the proxy with |
| * @param proxyPassword |
| * to authenticate at the proxy with |
| */ |
| public Socks5ClientConnector(@NonNull InetSocketAddress proxyAddress, |
| @NonNull InetSocketAddress remoteAddress, |
| String proxyUser, char[] proxyPassword) { |
| super(proxyAddress, remoteAddress, proxyUser, proxyPassword); |
| this.state = ProtocolState.NONE; |
| } |
| |
| @Override |
| public void sendClientProxyMetadata(ClientSession sshSession) |
| throws Exception { |
| init(sshSession); |
| IoSession session = sshSession.getIoSession(); |
| // Send the initial request |
| Buffer buffer = new ByteArrayBuffer(5, false); |
| buffer.putByte(SOCKS_VERSION_5); |
| context = getGSSContext(remoteAddress); |
| authenticationProposals = getAuthenticationProposals(); |
| buffer.putByte((byte) authenticationProposals.length); |
| buffer.putRawBytes(authenticationProposals); |
| state = ProtocolState.INIT; |
| session.writePacket(buffer).verify(getTimeout()); |
| } |
| |
| private byte[] getAuthenticationProposals() { |
| byte[] proposals = new byte[3]; |
| int i = 0; |
| proposals[i++] = SocksAuthenticationMethod.ANONYMOUS.getValue(); |
| proposals[i++] = SocksAuthenticationMethod.PASSWORD.getValue(); |
| if (context != null) { |
| proposals[i++] = SocksAuthenticationMethod.GSSAPI.getValue(); |
| } |
| if (i == proposals.length) { |
| return proposals; |
| } |
| byte[] result = new byte[i]; |
| System.arraycopy(proposals, 0, result, 0, i); |
| return result; |
| } |
| |
| private void sendConnectInfo(IoSession session) throws Exception { |
| GssApiMechanisms.closeContextSilently(context); |
| |
| byte[] rawAddress = getRawAddress(remoteAddress); |
| byte[] remoteName = null; |
| byte type; |
| int length = 0; |
| if (rawAddress == null) { |
| remoteName = remoteAddress.getHostString().getBytes(US_ASCII); |
| if (remoteName == null || remoteName.length == 0) { |
| throw new IOException( |
| format(SshdText.get().proxySocksNoRemoteHostName, |
| remoteAddress)); |
| } else if (remoteName.length > 255) { |
| // Should not occur; host names must not be longer than 255 |
| // US_ASCII characters. Internal error, no translation. |
| throw new IOException(format( |
| "Proxy host name too long for SOCKS (at most 255 characters): {0}", //$NON-NLS-1$ |
| remoteAddress.getHostString())); |
| } |
| type = SOCKS_ADDRESS_FQDN; |
| length = remoteName.length + 1; |
| } else { |
| length = rawAddress.length; |
| type = length == 4 ? SOCKS_ADDRESS_IPv4 : SOCKS_ADDRESS_IPv6; |
| } |
| Buffer buffer = new ByteArrayBuffer(4 + length + 2, false); |
| buffer.putByte(SOCKS_VERSION_5); |
| buffer.putByte(SOCKS_CMD_CONNECT); |
| buffer.putByte((byte) 0); // Reserved |
| buffer.putByte(type); |
| if (remoteName != null) { |
| buffer.putByte((byte) remoteName.length); |
| buffer.putRawBytes(remoteName); |
| } else { |
| buffer.putRawBytes(rawAddress); |
| } |
| int port = remoteAddress.getPort(); |
| if (port <= 0) { |
| port = SshConstants.SSH_DEFAULT_PORT; |
| } |
| buffer.putByte((byte) ((port >> 8) & 0xFF)); |
| buffer.putByte((byte) (port & 0xFF)); |
| state = ProtocolState.CONNECTING; |
| session.writePacket(buffer).verify(getTimeout()); |
| } |
| |
| private void doPasswordAuth(IoSession session) throws Exception { |
| GssApiMechanisms.closeContextSilently(context); |
| authenticator = new SocksBasicAuthentication(); |
| session.addCloseFutureListener(f -> close()); |
| startAuth(session); |
| } |
| |
| private void doGssApiAuth(IoSession session) throws Exception { |
| authenticator = new SocksGssApiAuthentication(); |
| session.addCloseFutureListener(f -> close()); |
| startAuth(session); |
| } |
| |
| private void close() { |
| AuthenticationHandler<?, ?> handler = authenticator; |
| authenticator = null; |
| if (handler != null) { |
| handler.close(); |
| } |
| } |
| |
| private void startAuth(IoSession session) throws Exception { |
| Buffer buffer = null; |
| try { |
| authenticator.setParams(null); |
| authenticator.start(); |
| buffer = authenticator.getToken(); |
| state = ProtocolState.AUTHENTICATING; |
| if (buffer == null) { |
| // Internal error; no translation |
| throw new IOException( |
| "No data for proxy authentication with " //$NON-NLS-1$ |
| + proxyAddress); |
| } |
| session.writePacket(buffer).verify(getTimeout()); |
| } finally { |
| if (buffer != null) { |
| buffer.clear(true); |
| } |
| } |
| } |
| |
| private void authStep(IoSession session, Buffer input) throws Exception { |
| Buffer buffer = null; |
| try { |
| authenticator.setParams(input); |
| authenticator.process(); |
| buffer = authenticator.getToken(); |
| if (buffer != null) { |
| session.writePacket(buffer).verify(getTimeout()); |
| } |
| } finally { |
| if (buffer != null) { |
| buffer.clear(true); |
| } |
| } |
| if (authenticator.isDone()) { |
| sendConnectInfo(session); |
| } |
| } |
| |
| private void establishConnection(Buffer data) throws Exception { |
| byte reply = data.getByte(); |
| switch (reply) { |
| case SOCKS_REPLY_SUCCESS: |
| state = ProtocolState.CONNECTED; |
| setDone(true); |
| return; |
| case SOCKS_REPLY_FAILURE: |
| throw new IOException(format( |
| SshdText.get().proxySocksFailureGeneral, proxyAddress)); |
| case SOCKS_REPLY_FORBIDDEN: |
| throw new IOException( |
| format(SshdText.get().proxySocksFailureForbidden, |
| proxyAddress, remoteAddress)); |
| case SOCKS_REPLY_NETWORK_UNREACHABLE: |
| throw new IOException( |
| format(SshdText.get().proxySocksFailureNetworkUnreachable, |
| proxyAddress, remoteAddress)); |
| case SOCKS_REPLY_HOST_UNREACHABLE: |
| throw new IOException( |
| format(SshdText.get().proxySocksFailureHostUnreachable, |
| proxyAddress, remoteAddress)); |
| case SOCKS_REPLY_CONNECTION_REFUSED: |
| throw new IOException( |
| format(SshdText.get().proxySocksFailureRefused, |
| proxyAddress, remoteAddress)); |
| case SOCKS_REPLY_TTL_EXPIRED: |
| throw new IOException( |
| format(SshdText.get().proxySocksFailureTTL, proxyAddress)); |
| case SOCKS_REPLY_COMMAND_UNSUPPORTED: |
| throw new IOException( |
| format(SshdText.get().proxySocksFailureUnsupportedCommand, |
| proxyAddress)); |
| case SOCKS_REPLY_ADDRESS_UNSUPPORTED: |
| throw new IOException( |
| format(SshdText.get().proxySocksFailureUnsupportedAddress, |
| proxyAddress)); |
| default: |
| throw new IOException(format( |
| SshdText.get().proxySocksFailureUnspecified, proxyAddress)); |
| } |
| } |
| |
| @Override |
| public void messageReceived(IoSession session, Readable buffer) |
| throws Exception { |
| try { |
| // Dispatch according to protocol state |
| ByteArrayBuffer data = new ByteArrayBuffer(buffer.available(), |
| false); |
| data.putBuffer(buffer); |
| data.compact(); |
| state.handleMessage(this, session, data); |
| } catch (Exception e) { |
| state = ProtocolState.FAILED; |
| if (authenticator != null) { |
| authenticator.close(); |
| authenticator = null; |
| } |
| try { |
| setDone(false); |
| } catch (Exception inner) { |
| e.addSuppressed(inner); |
| } |
| throw e; |
| } |
| } |
| |
| private void versionCheck(byte version) throws Exception { |
| if (version != SOCKS_VERSION_5) { |
| throw new IOException( |
| format(SshdText.get().proxySocksUnexpectedVersion, |
| Integer.toString(version & 0xFF))); |
| } |
| } |
| |
| private SocksAuthenticationMethod getAuthMethod(byte value) { |
| if (value != SocksAuthenticationMethod.NONE_ACCEPTABLE.getValue()) { |
| for (byte proposed : authenticationProposals) { |
| if (proposed == value) { |
| for (SocksAuthenticationMethod method : SocksAuthenticationMethod |
| .values()) { |
| if (method.getValue() == value) { |
| return method; |
| } |
| } |
| break; |
| } |
| } |
| } |
| return SocksAuthenticationMethod.NONE_ACCEPTABLE; |
| } |
| |
| private static byte[] getRawAddress(@NonNull InetSocketAddress address) { |
| InetAddress ipAddress = GssApiMechanisms.resolve(address); |
| return ipAddress == null ? null : ipAddress.getAddress(); |
| } |
| |
| private static GSSContext getGSSContext( |
| @NonNull InetSocketAddress address) { |
| if (!GssApiMechanisms.getSupportedMechanisms() |
| .contains(GssApiMechanisms.KERBEROS_5)) { |
| return null; |
| } |
| return GssApiMechanisms.createContext(GssApiMechanisms.KERBEROS_5, |
| GssApiMechanisms.getCanonicalName(address)); |
| } |
| |
| /** |
| * @see <a href="https://tools.ietf.org/html/rfc1929">RFC 1929</a> |
| */ |
| private class SocksBasicAuthentication |
| extends BasicAuthentication<Buffer, Buffer> { |
| |
| private static final byte SOCKS_BASIC_PROTOCOL_VERSION = 1; |
| |
| private static final byte SOCKS_BASIC_AUTH_SUCCESS = 0; |
| |
| public SocksBasicAuthentication() { |
| super(proxyAddress, proxyUser, proxyPassword); |
| } |
| |
| @Override |
| public void process() throws Exception { |
| // Retries impossible. RFC 1929 specifies that the server MUST |
| // close the connection if authentication is unsuccessful. |
| done = true; |
| if (params.getByte() != SOCKS_BASIC_PROTOCOL_VERSION |
| || params.getByte() != SOCKS_BASIC_AUTH_SUCCESS) { |
| throw new IOException(format( |
| SshdText.get().proxySocksAuthenticationFailed, proxy)); |
| } |
| } |
| |
| @Override |
| protected void askCredentials() { |
| super.askCredentials(); |
| adjustTimeout(); |
| } |
| |
| @Override |
| public Buffer getToken() throws IOException { |
| if (done) { |
| return null; |
| } |
| try { |
| byte[] rawUser = user.getBytes(UTF_8); |
| if (rawUser.length > 255) { |
| throw new IOException(format( |
| SshdText.get().proxySocksUsernameTooLong, proxy, |
| Integer.toString(rawUser.length), user)); |
| } |
| |
| if (password.length > 255) { |
| throw new IOException( |
| format(SshdText.get().proxySocksPasswordTooLong, |
| proxy, Integer.toString(password.length))); |
| } |
| ByteArrayBuffer buffer = new ByteArrayBuffer( |
| 3 + rawUser.length + password.length, false); |
| buffer.putByte(SOCKS_BASIC_PROTOCOL_VERSION); |
| buffer.putByte((byte) rawUser.length); |
| buffer.putRawBytes(rawUser); |
| buffer.putByte((byte) password.length); |
| buffer.putRawBytes(password); |
| return buffer; |
| } finally { |
| clearPassword(); |
| done = true; |
| } |
| } |
| } |
| |
| /** |
| * @see <a href="https://tools.ietf.org/html/rfc1961">RFC 1961</a> |
| */ |
| private class SocksGssApiAuthentication |
| extends GssApiAuthentication<Buffer, Buffer> { |
| |
| private static final byte SOCKS5_GSSAPI_VERSION = 1; |
| |
| private static final byte SOCKS5_GSSAPI_TOKEN = 1; |
| |
| private static final int SOCKS5_GSSAPI_FAILURE = 0xFF; |
| |
| public SocksGssApiAuthentication() { |
| super(proxyAddress); |
| } |
| |
| @Override |
| protected GSSContext createContext() throws Exception { |
| return context; |
| } |
| |
| @Override |
| public Buffer getToken() throws Exception { |
| if (token == null) { |
| return null; |
| } |
| Buffer buffer = new ByteArrayBuffer(4 + token.length, false); |
| buffer.putByte(SOCKS5_GSSAPI_VERSION); |
| buffer.putByte(SOCKS5_GSSAPI_TOKEN); |
| buffer.putByte((byte) ((token.length >> 8) & 0xFF)); |
| buffer.putByte((byte) (token.length & 0xFF)); |
| buffer.putRawBytes(token); |
| return buffer; |
| } |
| |
| @Override |
| protected byte[] extractToken(Buffer input) throws Exception { |
| if (context == null) { |
| return null; |
| } |
| int version = input.getUByte(); |
| if (version != SOCKS5_GSSAPI_VERSION) { |
| throw new IOException( |
| format(SshdText.get().proxySocksGssApiVersionMismatch, |
| remoteAddress, Integer.toString(version))); |
| } |
| int msgType = input.getUByte(); |
| if (msgType == SOCKS5_GSSAPI_FAILURE) { |
| throw new IOException(format( |
| SshdText.get().proxySocksGssApiFailure, remoteAddress)); |
| } else if (msgType != SOCKS5_GSSAPI_TOKEN) { |
| throw new IOException(format( |
| SshdText.get().proxySocksGssApiUnknownMessage, |
| remoteAddress, Integer.toHexString(msgType & 0xFF))); |
| } |
| if (input.available() >= 2) { |
| int length = (input.getUByte() << 8) + input.getUByte(); |
| if (input.available() >= length) { |
| byte[] value = new byte[length]; |
| if (length > 0) { |
| input.getRawBytes(value); |
| } |
| return value; |
| } |
| } |
| throw new IOException( |
| format(SshdText.get().proxySocksGssApiMessageTooShort, |
| remoteAddress)); |
| } |
| } |
| } |