Skip to content

Pass through original host to underlying TcpConnector for TLS setup (fixes SNI on legacy PHP < 5.6) #90

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 14, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,13 @@ the remote host rejects the connection etc.), it will reject with a

If you want to connect to hostname-port-combinations, see also the following chapter.

> Advanced usage: Internally, the `TcpConnector` allocates an empty *context*
resource for each stream resource.
If the destination URI contains a `hostname` query parameter, its value will
be used to set up the TLS peer name.
This is used by the `SecureConnector` and `DnsConnector` to verify the peer
name and can also be used if you want a custom TLS peer name.

### DNS resolution

The `DnsConnector` class implements the
Expand Down Expand Up @@ -288,6 +295,17 @@ $connector = new React\SocketClient\Connector($loop, $dns);
$connector->connect('www.google.com:80')->then($callback);
```

> Advanced usage: Internally, the `DnsConnector` relies on a `Resolver` to
look up the IP address for the given hostname.
It will then replace the hostname in the destination URI with this IP and
append a `hostname` query parameter and pass this updated URI to the underlying
connector.
The underlying connector is thus responsible for creating a connection to the
target IP address, while this query parameter can be used to check the original
hostname and is used by the `TcpConnector` to set up the TLS peer name.
If a `hostname` is given explicitly, this query parameter will not be modified,
which can be useful if you want a custom TLS peer name.

### Secure TLS connections

The `SecureConnector` class implements the
Expand Down Expand Up @@ -333,13 +351,14 @@ $secureConnector = new React\SocketClient\SecureConnector($dnsConnector, $loop,
));
```

> Advanced usage: Internally, the `SecureConnector` has to set the required
*context options* on the underlying stream resource.
> Advanced usage: Internally, the `SecureConnector` relies on setting up the
required *context options* on the underlying stream resource.
It should therefor be used with a `TcpConnector` somewhere in the connector
stack so that it can allocate an empty *context* resource for each stream
resource.
Failing to do so may result in some hard to trace race conditions, because all
stream resources will use a single, shared *default context* resource otherwise.
resource and verify the peer name.
Failing to do so may result in a TLS peer name mismatch error or some hard to
trace race conditions, because all stream resources will use a single, shared
*default context* resource otherwise.

### Connection timeouts

Expand Down
6 changes: 4 additions & 2 deletions examples/01-http.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,17 @@
// time out connection attempt in 3.0s
$dns = new TimeoutConnector($dns, 3.0, $loop);

$dns->connect('www.google.com:80')->then(function (ConnectionInterface $connection) {
$target = isset($argv[1]) ? $argv[1] : 'www.google.com:80';

$dns->connect($target)->then(function (ConnectionInterface $connection) use ($target) {
$connection->on('data', function ($data) {
echo $data;
});
$connection->on('close', function () {
echo '[CLOSED]' . PHP_EOL;
});

$connection->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n");
$connection->write("GET / HTTP/1.0\r\nHost: $target\r\n\r\n");
}, 'printf');

$loop->run();
6 changes: 4 additions & 2 deletions examples/02-https.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@
// time out connection attempt in 3.0s
$tls = new TimeoutConnector($tls, 3.0, $loop);

$tls->connect('www.google.com:443')->then(function (ConnectionInterface $connection) {
$target = isset($argv[1]) ? $argv[1] : 'www.google.com:443';

$tls->connect($target)->then(function (ConnectionInterface $connection) use ($target) {
$connection->on('data', function ($data) {
echo $data;
});
$connection->on('close', function () {
echo '[CLOSED]' . PHP_EOL;
});

$connection->write("GET / HTTP/1.0\r\nHost: www.google.com\r\n\r\n");
$connection->write("GET / HTTP/1.0\r\nHost: $target\r\n\r\n");
}, 'printf');

$loop->run();
11 changes: 9 additions & 2 deletions src/DnsConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,12 @@ public function connect($uri)
return Promise\reject(new \InvalidArgumentException('Given URI "' . $uri . '" is invalid'));
}

$that = $this;
$host = trim($parts['host'], '[]');
$connector = $this->connector;

return $this
->resolveHostname($host)
->then(function ($ip) use ($connector, $parts) {
->then(function ($ip) use ($connector, $host, $parts) {
$uri = '';

// prepend original scheme if known
Expand Down Expand Up @@ -66,6 +65,14 @@ public function connect($uri)
$uri .= '?' . $parts['query'];
}

// append original hostname as query if resolved via DNS and if
// destination URI does not contain "hostname" query param already
$args = array();
parse_str(isset($parts['query']) ? $parts['query'] : '', $args);
if ($host !== $ip && !isset($args['hostname'])) {
$uri .= (isset($parts['query']) ? '&' : '?') . 'hostname=' . rawurlencode($host);
}

// append original fragment if known
if (isset($parts['fragment'])) {
$uri .= '#' . $parts['fragment'];
Expand Down
17 changes: 2 additions & 15 deletions src/SecureConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,25 +30,12 @@ public function connect($uri)
}

$parts = parse_url($uri);
if (!$parts || !isset($parts['host']) || $parts['scheme'] !== 'tls') {
if (!$parts || !isset($parts['scheme']) || $parts['scheme'] !== 'tls') {
return Promise\reject(new \InvalidArgumentException('Given URI "' . $uri . '" is invalid'));
}

$uri = str_replace('tls://', '', $uri);
$host = trim($parts['host'], '[]');

$context = $this->context + array(
'SNI_enabled' => true,
'peer_name' => $host
);

// legacy PHP < 5.6 ignores peer_name and requires legacy context options instead
if (PHP_VERSION_ID < 50600) {
$context += array(
'SNI_server_name' => $host,
'CN_match' => $host
);
}
$context = $this->context;

$encryption = $this->streamEncryption;
return $this->connector->connect($uri)->then(function (ConnectionInterface $connection) use ($context, $encryption) {
Expand Down
41 changes: 40 additions & 1 deletion src/TcpConnector.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,52 @@ public function connect($uri)
return Promise\reject(new \InvalidArgumentException('Given URI "' . $ip . '" does not contain a valid host IP'));
}

// use context given in constructor
$context = array(
'socket' => $this->context
);

// parse arguments from query component of URI
$args = array();
if (isset($parts['query'])) {
parse_str($parts['query'], $args);
}

// If an original hostname has been given, use this for TLS setup.
// This can happen due to layers of nested connectors, such as a
// DnsConnector reporting its original hostname.
// These context options are here in case TLS is enabled later on this stream.
// If TLS is not enabled later, this doesn't hurt either.
if (isset($args['hostname'])) {
$context['ssl'] = array(
'SNI_enabled' => true,
'peer_name' => $args['hostname']
);

// Legacy PHP < 5.6 ignores peer_name and requires legacy context options instead.
// The SNI_server_name context option has to be set here during construction,
// as legacy PHP ignores any values set later.
if (PHP_VERSION_ID < 50600) {
$context['ssl'] += array(
'SNI_server_name' => $args['hostname'],
'CN_match' => $args['hostname']
);
}
}

// HHVM fails to parse URIs with a query but no path, so let's add a dummy path
// See also https://3v4l.org/jEhLF
if (defined('HHVM_VERSION') && isset($parts['query']) && !isset($parts['path'])) {
$uri = str_replace('?', '/?', $uri);
}

$socket = @stream_socket_client(
$uri,
$errno,
$errstr,
0,
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT,
stream_context_create(array('socket' => $this->context))
stream_context_create($context)
);

if (false === $socket) {
Expand Down
22 changes: 19 additions & 3 deletions tests/DnsConnectorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,15 @@ public function testPassByResolverIfGivenIp()
public function testPassThroughResolverIfGivenHost()
{
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80'))->will($this->returnValue(Promise\reject()));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80?hostname=google.com'))->will($this->returnValue(Promise\reject()));

$this->connector->connect('google.com:80');
}

public function testPassThroughResolverIfGivenHostWhichResolvesToIpv6()
{
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('::1')));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('[::1]:80'))->will($this->returnValue(Promise\reject()));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('[::1]:80?hostname=google.com'))->will($this->returnValue(Promise\reject()));

$this->connector->connect('google.com:80');
}
Expand All @@ -51,6 +51,22 @@ public function testPassByResolverIfGivenCompleteUri()
$this->connector->connect('scheme://127.0.0.1:80/path?query#fragment');
}

public function testPassThroughResolverIfGivenCompleteUri()
{
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('scheme://1.2.3.4:80/path?query&hostname=google.com#fragment'))->will($this->returnValue(Promise\reject()));

$this->connector->connect('scheme://google.com:80/path?query#fragment');
}

public function testPassThroughResolverIfGivenExplicitHost()
{
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('scheme://1.2.3.4:80/?hostname=google.de'))->will($this->returnValue(Promise\reject()));

$this->connector->connect('scheme://google.com:80/?hostname=google.de');
}

public function testRejectsImmediatelyIfUriIsInvalid()
{
$this->resolver->expects($this->never())->method('resolve');
Expand Down Expand Up @@ -85,7 +101,7 @@ public function testCancelDuringTcpConnectionCancelsTcpConnection()
{
$pending = new Promise\Promise(function () { }, function () { throw new \Exception(); });
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('example.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80'))->will($this->returnValue($pending));
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80?hostname=example.com'))->will($this->returnValue($pending));

$promise = $this->connector->connect('example.com:80');
$promise->cancel();
Expand Down
30 changes: 30 additions & 0 deletions tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use React\SocketClient\TcpConnector;
use React\Stream\BufferedSink;
use Clue\React\Block;
use React\SocketClient\DnsConnector;

class IntegrationTest extends TestCase
{
Expand Down Expand Up @@ -62,6 +63,35 @@ public function gettingEncryptedStuffFromGoogleShouldWork()
$this->assertRegExp('#^HTTP/1\.0#', $response);
}

/** @test */
public function gettingEncryptedStuffFromGoogleShouldWorkIfHostIsResolvedFirst()
{
if (!function_exists('stream_socket_enable_crypto')) {
$this->markTestSkipped('Not supported on your platform (outdated HHVM?)');
}

$loop = new StreamSelectLoop();

$factory = new Factory();
$dns = $factory->create('8.8.8.8', $loop);

$connector = new DnsConnector(
new SecureConnector(
new TcpConnector($loop),
$loop
),
$dns
);

$conn = Block\await($connector->connect('google.com:443'), $loop);

$conn->write("GET / HTTP/1.0\r\n\r\n");

$response = Block\await(BufferedSink::createPromise($conn), $loop, self::TIMEOUT);

$this->assertRegExp('#^HTTP/1\.0#', $response);
}

/** @test */
public function testSelfSignedRejectsIfVerificationIsEnabled()
{
Expand Down