From 9a7c21d7beece1e550e948a575158f7e6a78f650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 27 Aug 2017 11:39:23 +0200 Subject: [PATCH] Report matching SOCKS5 error codes for server side connection errors --- src/Server.php | 50 +++++++++++++++++++---- tests/ServerTest.php | 94 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 8 deletions(-) diff --git a/src/Server.php b/src/Server.php index bfb4e87..3605160 100644 --- a/src/Server.php +++ b/src/Server.php @@ -14,9 +14,28 @@ use \UnexpectedValueException; use \InvalidArgumentException; use \Exception; +use React\Promise\Timer\TimeoutException; class Server extends EventEmitter { + // the following error codes are only used for SOCKS5 only + /** @internal */ + const ERROR_GENERAL = 0x01; + /** @internal */ + const ERROR_NOT_ALLOWED_BY_RULESET = 0x02; + /** @internal */ + const ERROR_NETWORK_UNREACHABLE = 0x03; + /** @internal */ + const ERROR_HOST_UNREACHABLE = 0x04; + /** @internal */ + const ERROR_CONNECTION_REFUSED = 0x05; + /** @internal */ + const ERROR_TTL = 0x06; + /** @internal */ + const ERROR_COMMAND_UNSUPPORTED = 0x07; + /** @internal */ + const ERROR_ADDRESS_UNSUPPORTED = 0x08; + protected $loop; private $connector; @@ -274,7 +293,7 @@ public function handleSocks5(ConnectionInterface $stream, $auth=null, StreamRead }); } else { // reject all offered authentication methods - $stream->end(pack('C2', 0x05, 0xFF)); + $stream->write(pack('C2', 0x05, 0xFF)); throw new UnexpectedValueException('No acceptable authentication mechanism found'); } })->then(function ($method) use ($reader, $stream) { @@ -289,7 +308,7 @@ public function handleSocks5(ConnectionInterface $stream, $auth=null, StreamRead throw new UnexpectedValueException('Invalid SOCKS version'); } if ($data['command'] !== 0x01) { - throw new UnexpectedValueException('Only CONNECT requests supported'); + throw new UnexpectedValueException('Only CONNECT requests supported', Server::ERROR_COMMAND_UNSUPPORTED); } // if ($data['null'] !== 0x00) { // throw new UnexpectedValueException('Reserved byte has to be NULL'); @@ -310,7 +329,7 @@ public function handleSocks5(ConnectionInterface $stream, $auth=null, StreamRead return inet_ntop($addr); }); } else { - throw new UnexpectedValueException('Invalid target type'); + throw new UnexpectedValueException('Invalid address type', Server::ERROR_ADDRESS_UNSUPPORTED); } })->then(function ($host) use ($reader, &$remote) { return $reader->readBinary(array('port'=>'n'))->then(function ($data) use ($host, &$remote) { @@ -319,14 +338,13 @@ public function handleSocks5(ConnectionInterface $stream, $auth=null, StreamRead })->then(function ($target) use ($that, $stream) { return $that->connectTarget($stream, $target); }, function($error) use ($stream) { - throw new UnexpectedValueException('SOCKS5 protocol error',0,$error); + throw new UnexpectedValueException('SOCKS5 protocol error', $error->getCode(), $error); })->then(function (ConnectionInterface $remote) use ($stream) { $stream->write(pack('C4Nn', 0x05, 0x00, 0x00, 0x01, 0, 0)); return $remote; }, function(Exception $error) use ($stream){ - $code = 0x01; - $stream->end(pack('C4Nn', 0x05, $code, 0x00, 0x01, 0, 0)); + $stream->write(pack('C4Nn', 0x05, $error->getCode() === 0 ? Server::ERROR_GENERAL : $error->getCode(), 0x00, 0x01, 0, 0)); throw $error; }); @@ -378,7 +396,25 @@ public function connectTarget(ConnectionInterface $stream, array $target) return $remote; }, function(Exception $error) { - throw new UnexpectedValueException('Unable to connect to remote target', 0, $error); + // default to general/unknown error + $code = Server::ERROR_GENERAL; + + // map common socket error conditions to limited list of SOCKS error codes + if ((defined('SOCKET_EACCES') && $error->getCode() === SOCKET_EACCES) || $error->getCode() === 13) { + $code = Server::ERROR_NOT_ALLOWED_BY_RULESET; + } elseif ((defined('SOCKET_EHOSTUNREACH') && $error->getCode() === SOCKET_EHOSTUNREACH) || $error->getCode() === 113) { + $code = Server::ERROR_HOST_UNREACHABLE; + } elseif ((defined('SOCKET_ENETUNREACH') && $error->getCode() === SOCKET_ENETUNREACH) || $error->getCode() === 101) { + $code = Server::ERROR_NETWORK_UNREACHABLE; + } elseif ((defined('SOCKET_ECONNREFUSED') && $error->getCode() === SOCKET_ECONNREFUSED) || $error->getCode() === 111 || $error->getMessage() === 'Connection refused') { + // Socket component does not currently assign an error code for this, so we have to resort to checking the exception message + $code = Server::ERROR_CONNECTION_REFUSED; + } elseif ((defined('SOCKET_ETIMEDOUT') && $error->getCode() === SOCKET_ETIMEDOUT) || $error->getCode() === 110 || $error instanceof TimeoutException) { + // Socket component does not currently assign an error code for this, but we can rely on the TimeoutException + $code = Server::ERROR_TTL; + } + + throw new UnexpectedValueException('Unable to connect to remote target', $code, $error); }); } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 612976b..e01cdee 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -2,6 +2,7 @@ use Clue\React\Socks\Server; use React\Promise\Promise; +use React\Promise\Timer\TimeoutException; class ServerTest extends TestCase { @@ -154,6 +155,67 @@ public function testConnectWillAbortIfPromiseIsCancelled() $promise->then(null, $this->expectCallableOnce()); } + public function provideConnectionErrors() + { + return array( + array( + new RuntimeException('', SOCKET_EACCES), + Server::ERROR_NOT_ALLOWED_BY_RULESET + ), + array( + new RuntimeException('', SOCKET_ENETUNREACH), + Server::ERROR_NETWORK_UNREACHABLE + ), + array( + new RuntimeException('', SOCKET_EHOSTUNREACH), + Server::ERROR_HOST_UNREACHABLE, + ), + array( + new RuntimeException('', SOCKET_ECONNREFUSED), + Server::ERROR_CONNECTION_REFUSED + ), + array( + new RuntimeException('Connection refused'), + Server::ERROR_CONNECTION_REFUSED + ), + array( + new RuntimeException('', SOCKET_ETIMEDOUT), + Server::ERROR_TTL + ), + array( + new TimeoutException(1.0), + Server::ERROR_TTL + ), + array( + new RuntimeException(), + Server::ERROR_GENERAL + ) + ); + } + + /** + * @dataProvider provideConnectionErrors + * @param Exception $error + * @param int $expectedCode + */ + public function testConnectWillReturnMappedSocks5ErrorCodeFromConnector($error, $expectedCode) + { + $stream = $this->getMockBuilder('React\Socket\ConnectionInterface')->getMock(); + + $promise = \React\Promise\reject($error); + + $this->connector->expects($this->once())->method('connect')->willReturn($promise); + + $promise = $this->server->connectTarget($stream, array('google.com', 80)); + + $code = null; + $promise->then(null, function ($error) use (&$code) { + $code = $error->getCode(); + }); + + $this->assertEquals($expectedCode, $code); + } + public function testHandleSocksConnectionWillEndOnInvalidData() { $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('pause', 'end'))->getMock(); @@ -269,7 +331,22 @@ public function testHandleSocks5ConnectionWithHostnameWillEstablishOutgoingConne $connection->emit('data', array("\x05\x01\x00" . "\x05\x01\x00\x03\x0B" . "example.com" . "\x00\x50")); } - public function testHandleSocks5ConnectionWithInvalidHostnameWillNotEstablishOutgoingConnection() + public function testHandleSocks5ConnectionWithConnectorRefusedWillReturnReturnRefusedError() + { + $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('pause', 'end', 'write'))->getMock(); + + $promise = \React\Promise\reject(new RuntimeException('Connection refused')); + + $this->connector->expects($this->once())->method('connect')->with('example.com:80')->willReturn($promise); + + $this->server->onConnection($connection); + + $connection->expects($this->exactly(2))->method('write')->withConsecutive(array("\x05\x00"), array("\x05\x05" . "\x00\x01\x00\x00\x00\x00\x00\x00")); + + $connection->emit('data', array("\x05\x01\x00" . "\x05\x01\x00\x03\x0B" . "example.com" . "\x00\x50")); + } + + public function testHandleSocks5UdpCommandWillNotEstablishOutgoingConnectionAndReturnCommandError() { $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('pause', 'end', 'write'))->getMock(); @@ -277,6 +354,21 @@ public function testHandleSocks5ConnectionWithInvalidHostnameWillNotEstablishOut $this->server->onConnection($connection); + $connection->expects($this->exactly(2))->method('write')->withConsecutive(array("\x05\x00"), array("\x05\x07" . "\x00\x01\x00\x00\x00\x00\x00\x00")); + + $connection->emit('data', array("\x05\x01\x00" . "\x05\x03\x00\x03\x0B" . "example.com" . "\x00\x50")); + } + + public function testHandleSocks5ConnectionWithInvalidHostnameWillNotEstablishOutgoingConnectionAndReturnGeneralError() + { + $connection = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('pause', 'end', 'write'))->getMock(); + + $this->connector->expects($this->never())->method('connect'); + + $this->server->onConnection($connection); + + $connection->expects($this->exactly(2))->method('write')->withConsecutive(array("\x05\x00"), array("\x05\x01" . "\x00\x01\x00\x00\x00\x00\x00\x00")); + $connection->emit('data', array("\x05\x01\x00" . "\x05\x01\x00\x03\x15" . "tls://example.com:80?" . "\x00\x50")); }