Skip to content

Commit

Permalink
Merge pull request #17 from clue-labs/errors
Browse files Browse the repository at this point in the history
Use socket error codes for connection rejections
  • Loading branch information
clue authored Aug 30, 2017
2 parents 104d568 + 7258a76 commit 1b23773
Show file tree
Hide file tree
Showing 5 changed files with 77 additions and 18 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,8 @@ $proxy = new ProxyConnector(
connection attempt.
If the authentication details are missing or not accepted by the remote HTTP
proxy server, it is expected to reject each connection attempt with a
`407` (Proxy Authentication Required) response status code.
`407` (Proxy Authentication Required) response status code and an exception
error code of `SOCKET_EACCES` (13).

#### Advanced secure proxy connections

Expand Down
25 changes: 15 additions & 10 deletions src/ProxyConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public function connect($uri)

return $this->connector->connect($proxyUri)->then(function (ConnectionInterface $stream) use ($host, $port, $auth) {
$deferred = new Deferred(function ($_, $reject) use ($stream) {
$reject(new RuntimeException('Operation canceled while waiting for response from proxy'));
$reject(new RuntimeException('Connection canceled while waiting for response from proxy (ECONNABORTED)', defined('SOCKET_ECONNABORTED') ? SOCKET_ECONNABORTED : 103));
$stream->close();
});

Expand All @@ -146,16 +146,19 @@ public function connect($uri)
try {
$response = Psr7\parse_response(substr($buffer, 0, $pos));
} catch (Exception $e) {
$deferred->reject(new RuntimeException('Invalid response received from proxy: ' . $e->getMessage(), 0, $e));
$deferred->reject(new RuntimeException('Invalid response received from proxy (EBADMSG)', defined('SOCKET_EBADMSG') ? SOCKET_EBADMSG: 71, $e));
$stream->close();
return;
}

// status must be 2xx
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
$deferred->reject(new RuntimeException('Proxy rejected with HTTP error code: ' . $response->getStatusCode() . ' ' . $response->getReasonPhrase(), $response->getStatusCode()));
$stream->close();
return;
if ($response->getStatusCode() === 407) {
// map status code 407 (Proxy Authentication Required) to EACCES
$deferred->reject(new RuntimeException('Proxy denied connection due to invalid authentication ' . $response->getStatusCode() . ' (' . $response->getReasonPhrase() . ') (EACCES)', defined('SOCKET_EACCES') ? SOCKET_EACCES : 13));
return $stream->close();
} elseif ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
// map non-2xx status code to ECONNREFUSED
$deferred->reject(new RuntimeException('Proxy refused connection with HTTP error code ' . $response->getStatusCode() . ' (' . $response->getReasonPhrase() . ') (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111));
return $stream->close();
}

// all okay, resolve with stream instance
Expand All @@ -172,23 +175,25 @@ public function connect($uri)

// stop buffering when 8 KiB have been read
if (isset($buffer[8192])) {
$deferred->reject(new RuntimeException('Proxy must not send more than 8 KiB of headers'));
$deferred->reject(new RuntimeException('Proxy must not send more than 8 KiB of headers (EMSGSIZE)', defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 90));
$stream->close();
}
};
$stream->on('data', $fn);

$stream->on('error', function (Exception $e) use ($deferred) {
$deferred->reject(new RuntimeException('Stream error while waiting for response from proxy', 0, $e));
$deferred->reject(new RuntimeException('Stream error while waiting for response from proxy (EIO)', defined('SOCKET_EIO') ? SOCKET_EIO : 5, $e));
});

$stream->on('close', function () use ($deferred) {
$deferred->reject(new RuntimeException('Connection to proxy lost while waiting for response'));
$deferred->reject(new RuntimeException('Connection to proxy lost while waiting for response (ECONNRESET)', defined('SOCKET_ECONNRESET') ? SOCKET_ECONNRESET : 104));
});

$stream->write("CONNECT " . $host . ":" . $port . " HTTP/1.1\r\nHost: " . $host . ":" . $port . "\r\n" . $auth . "\r\n");

return $deferred->promise();
}, function (Exception $e) use ($proxyUri) {
throw new RuntimeException('Unable to connect to proxy (ECONNREFUSED)', defined('SOCKET_ECONNREFUSED') ? SOCKET_ECONNREFUSED : 111, $e);
});
}
}
14 changes: 14 additions & 0 deletions tests/AbstractTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ protected function expectCallableOnceWith($value)
return $mock;
}

protected function expectCallableOnceWithExceptionCode($code)
{
$mock = $this->createCallableMock();
$mock
->expects($this->once())
->method('__invoke')
->with($this->callback(function ($e) use ($code) {
return $e->getCode() === $code;
}));

return $mock;
}


protected function expectCallableOnceParameter($type)
{
$mock = $this->createCallableMock();
Expand Down
16 changes: 13 additions & 3 deletions tests/FunctionalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,23 @@ public function setUp()
$this->dnsConnector = new DnsConnector($this->tcpConnector, $resolver);
}

public function testNonListeningSocketRejectsConnection()
{
$proxy = new ProxyConnector('127.0.0.1:9999', $this->dnsConnector);

$promise = $proxy->connect('google.com:80');

$this->setExpectedException('RuntimeException', 'Unable to connect to proxy', SOCKET_ECONNREFUSED);
Block\await($promise, $this->loop, 3.0);
}

public function testPlainGoogleDoesNotAcceptConnectMethod()
{
$proxy = new ProxyConnector('google.com', $this->dnsConnector);

$promise = $proxy->connect('google.com:80');

$this->setExpectedException('RuntimeException', 'Method Not Allowed', 405);
$this->setExpectedException('RuntimeException', '405 (Method Not Allowed)', SOCKET_ECONNREFUSED);
Block\await($promise, $this->loop, 3.0);
}

Expand All @@ -49,7 +59,7 @@ public function testSecureGoogleDoesNotAcceptConnectMethod()

$promise = $proxy->connect('google.com:80');

$this->setExpectedException('RuntimeException', 'Method Not Allowed', 405);
$this->setExpectedException('RuntimeException', '405 (Method Not Allowed)', SOCKET_ECONNREFUSED);
Block\await($promise, $this->loop, 3.0);
}

Expand All @@ -59,7 +69,7 @@ public function testSecureGoogleDoesNotAcceptPlainStream()

$promise = $proxy->connect('google.com:80');

$this->setExpectedException('RuntimeException', 'Connection to proxy lost');
$this->setExpectedException('RuntimeException', 'Connection to proxy lost', SOCKET_ECONNRESET);
Block\await($promise, $this->loop, 3.0);
}
}
37 changes: 33 additions & 4 deletions tests/ProxyConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,18 @@ public function testRejectsUriWithNonTcpScheme()
$promise->then(null, $this->expectCallableOnce());
}

public function testRejectsIfConnectorRejects()
{
$promise = \React\Promise\reject(new \RuntimeException());
$this->connector->expects($this->once())->method('connect')->willReturn($promise);

$proxy = new ProxyConnector('proxy.example.com', $this->connector);

$promise = $proxy->connect('google.com:80');

$promise->then(null, $this->expectCallableOnce());
}

public function testRejectsAndClosesIfStreamWritesNonHttp()
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('close', 'write'))->getMock();
Expand All @@ -176,7 +188,7 @@ public function testRejectsAndClosesIfStreamWritesNonHttp()
$stream->expects($this->once())->method('close');
$stream->emit('data', array("invalid\r\n\r\n"));

$promise->then(null, $this->expectCallableOnce());
$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EBADMSG));
}

public function testRejectsAndClosesIfStreamWritesTooMuchData()
Expand All @@ -193,7 +205,24 @@ public function testRejectsAndClosesIfStreamWritesTooMuchData()
$stream->expects($this->once())->method('close');
$stream->emit('data', array(str_repeat('*', 100000)));

$promise->then(null, $this->expectCallableOnce());
$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EMSGSIZE));
}

public function testRejectsAndClosesIfStreamReturnsProyAuthenticationRequired()
{
$stream = $this->getMockBuilder('React\Socket\Connection')->disableOriginalConstructor()->setMethods(array('close', 'write'))->getMock();

$promise = \React\Promise\resolve($stream);
$this->connector->expects($this->once())->method('connect')->willReturn($promise);

$proxy = new ProxyConnector('proxy.example.com', $this->connector);

$promise = $proxy->connect('google.com:80');

$stream->expects($this->once())->method('close');
$stream->emit('data', array("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"));

$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_EACCES));
}

public function testRejectsAndClosesIfStreamReturnsNonSuccess()
Expand All @@ -210,7 +239,7 @@ public function testRejectsAndClosesIfStreamReturnsNonSuccess()
$stream->expects($this->once())->method('close');
$stream->emit('data', array("HTTP/1.1 403 Not allowed\r\n\r\n"));

$promise->then(null, $this->expectCallableOnce());
$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNREFUSED));
}

public function testResolvesIfStreamReturnsSuccess()
Expand Down Expand Up @@ -268,6 +297,6 @@ public function testCancelPromiseWillCloseOpenConnectionAndReject()

$promise->cancel();

$promise->then(null, $this->expectCallableOnce());
$promise->then(null, $this->expectCallableOnceWithExceptionCode(SOCKET_ECONNABORTED));
}
}

0 comments on commit 1b23773

Please sign in to comment.