Skip to content

Commit 773417f

Browse files
authored
Merge pull request #90 from clue-labs/sni
Pass through original host to underlying TcpConnector for TLS setup (fixes SNI on legacy PHP < 5.6)
2 parents a141bb1 + f797b6a commit 773417f

File tree

8 files changed

+132
-30
lines changed

8 files changed

+132
-30
lines changed

README.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ the remote host rejects the connection etc.), it will reject with a
239239

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

242+
> Advanced usage: Internally, the `TcpConnector` allocates an empty *context*
243+
resource for each stream resource.
244+
If the destination URI contains a `hostname` query parameter, its value will
245+
be used to set up the TLS peer name.
246+
This is used by the `SecureConnector` and `DnsConnector` to verify the peer
247+
name and can also be used if you want a custom TLS peer name.
248+
242249
### DNS resolution
243250

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

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

293311
The `SecureConnector` class implements the
@@ -333,13 +351,14 @@ $secureConnector = new React\SocketClient\SecureConnector($dnsConnector, $loop,
333351
));
334352
```
335353

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

344363
### Connection timeouts
345364

examples/01-http.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@
1919
// time out connection attempt in 3.0s
2020
$dns = new TimeoutConnector($dns, 3.0, $loop);
2121

22-
$dns->connect('www.google.com:80')->then(function (ConnectionInterface $connection) {
22+
$target = isset($argv[1]) ? $argv[1] : 'www.google.com:80';
23+
24+
$dns->connect($target)->then(function (ConnectionInterface $connection) use ($target) {
2325
$connection->on('data', function ($data) {
2426
echo $data;
2527
});
2628
$connection->on('close', function () {
2729
echo '[CLOSED]' . PHP_EOL;
2830
});
2931

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

3335
$loop->run();

examples/02-https.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,17 @@
2121
// time out connection attempt in 3.0s
2222
$tls = new TimeoutConnector($tls, 3.0, $loop);
2323

24-
$tls->connect('www.google.com:443')->then(function (ConnectionInterface $connection) {
24+
$target = isset($argv[1]) ? $argv[1] : 'www.google.com:443';
25+
26+
$tls->connect($target)->then(function (ConnectionInterface $connection) use ($target) {
2527
$connection->on('data', function ($data) {
2628
echo $data;
2729
});
2830
$connection->on('close', function () {
2931
echo '[CLOSED]' . PHP_EOL;
3032
});
3133

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

3537
$loop->run();

src/DnsConnector.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,12 @@ public function connect($uri)
3030
return Promise\reject(new \InvalidArgumentException('Given URI "' . $uri . '" is invalid'));
3131
}
3232

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

3736
return $this
3837
->resolveHostname($host)
39-
->then(function ($ip) use ($connector, $parts) {
38+
->then(function ($ip) use ($connector, $host, $parts) {
4039
$uri = '';
4140

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

68+
// append original hostname as query if resolved via DNS and if
69+
// destination URI does not contain "hostname" query param already
70+
$args = array();
71+
parse_str(isset($parts['query']) ? $parts['query'] : '', $args);
72+
if ($host !== $ip && !isset($args['hostname'])) {
73+
$uri .= (isset($parts['query']) ? '&' : '?') . 'hostname=' . rawurlencode($host);
74+
}
75+
6976
// append original fragment if known
7077
if (isset($parts['fragment'])) {
7178
$uri .= '#' . $parts['fragment'];

src/SecureConnector.php

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,12 @@ public function connect($uri)
3030
}
3131

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

3737
$uri = str_replace('tls://', '', $uri);
38-
$host = trim($parts['host'], '[]');
39-
40-
$context = $this->context + array(
41-
'SNI_enabled' => true,
42-
'peer_name' => $host
43-
);
44-
45-
// legacy PHP < 5.6 ignores peer_name and requires legacy context options instead
46-
if (PHP_VERSION_ID < 50600) {
47-
$context += array(
48-
'SNI_server_name' => $host,
49-
'CN_match' => $host
50-
);
51-
}
38+
$context = $this->context;
5239

5340
$encryption = $this->streamEncryption;
5441
return $this->connector->connect($uri)->then(function (ConnectionInterface $connection) use ($context, $encryption) {

src/TcpConnector.php

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,52 @@ public function connect($uri)
3333
return Promise\reject(new \InvalidArgumentException('Given URI "' . $ip . '" does not contain a valid host IP'));
3434
}
3535

36+
// use context given in constructor
37+
$context = array(
38+
'socket' => $this->context
39+
);
40+
41+
// parse arguments from query component of URI
42+
$args = array();
43+
if (isset($parts['query'])) {
44+
parse_str($parts['query'], $args);
45+
}
46+
47+
// If an original hostname has been given, use this for TLS setup.
48+
// This can happen due to layers of nested connectors, such as a
49+
// DnsConnector reporting its original hostname.
50+
// These context options are here in case TLS is enabled later on this stream.
51+
// If TLS is not enabled later, this doesn't hurt either.
52+
if (isset($args['hostname'])) {
53+
$context['ssl'] = array(
54+
'SNI_enabled' => true,
55+
'peer_name' => $args['hostname']
56+
);
57+
58+
// Legacy PHP < 5.6 ignores peer_name and requires legacy context options instead.
59+
// The SNI_server_name context option has to be set here during construction,
60+
// as legacy PHP ignores any values set later.
61+
if (PHP_VERSION_ID < 50600) {
62+
$context['ssl'] += array(
63+
'SNI_server_name' => $args['hostname'],
64+
'CN_match' => $args['hostname']
65+
);
66+
}
67+
}
68+
69+
// HHVM fails to parse URIs with a query but no path, so let's add a dummy path
70+
// See also https://3v4l.org/jEhLF
71+
if (defined('HHVM_VERSION') && isset($parts['query']) && !isset($parts['path'])) {
72+
$uri = str_replace('?', '/?', $uri);
73+
}
74+
3675
$socket = @stream_socket_client(
3776
$uri,
3877
$errno,
3978
$errstr,
4079
0,
4180
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT,
42-
stream_context_create(array('socket' => $this->context))
81+
stream_context_create($context)
4382
);
4483

4584
if (false === $socket) {

tests/DnsConnectorTest.php

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ public function testPassByResolverIfGivenIp()
3030
public function testPassThroughResolverIfGivenHost()
3131
{
3232
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
33-
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80'))->will($this->returnValue(Promise\reject()));
33+
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80?hostname=google.com'))->will($this->returnValue(Promise\reject()));
3434

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

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

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

54+
public function testPassThroughResolverIfGivenCompleteUri()
55+
{
56+
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
57+
$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()));
58+
59+
$this->connector->connect('scheme://google.com:80/path?query#fragment');
60+
}
61+
62+
public function testPassThroughResolverIfGivenExplicitHost()
63+
{
64+
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('google.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
65+
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('scheme://1.2.3.4:80/?hostname=google.de'))->will($this->returnValue(Promise\reject()));
66+
67+
$this->connector->connect('scheme://google.com:80/?hostname=google.de');
68+
}
69+
5470
public function testRejectsImmediatelyIfUriIsInvalid()
5571
{
5672
$this->resolver->expects($this->never())->method('resolve');
@@ -85,7 +101,7 @@ public function testCancelDuringTcpConnectionCancelsTcpConnection()
85101
{
86102
$pending = new Promise\Promise(function () { }, function () { throw new \Exception(); });
87103
$this->resolver->expects($this->once())->method('resolve')->with($this->equalTo('example.com'))->will($this->returnValue(Promise\resolve('1.2.3.4')));
88-
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80'))->will($this->returnValue($pending));
104+
$this->tcp->expects($this->once())->method('connect')->with($this->equalTo('1.2.3.4:80?hostname=example.com'))->will($this->returnValue($pending));
89105

90106
$promise = $this->connector->connect('example.com:80');
91107
$promise->cancel();

tests/IntegrationTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use React\SocketClient\TcpConnector;
1111
use React\Stream\BufferedSink;
1212
use Clue\React\Block;
13+
use React\SocketClient\DnsConnector;
1314

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

66+
/** @test */
67+
public function gettingEncryptedStuffFromGoogleShouldWorkIfHostIsResolvedFirst()
68+
{
69+
if (!function_exists('stream_socket_enable_crypto')) {
70+
$this->markTestSkipped('Not supported on your platform (outdated HHVM?)');
71+
}
72+
73+
$loop = new StreamSelectLoop();
74+
75+
$factory = new Factory();
76+
$dns = $factory->create('8.8.8.8', $loop);
77+
78+
$connector = new DnsConnector(
79+
new SecureConnector(
80+
new TcpConnector($loop),
81+
$loop
82+
),
83+
$dns
84+
);
85+
86+
$conn = Block\await($connector->connect('google.com:443'), $loop);
87+
88+
$conn->write("GET / HTTP/1.0\r\n\r\n");
89+
90+
$response = Block\await(BufferedSink::createPromise($conn), $loop, self::TIMEOUT);
91+
92+
$this->assertRegExp('#^HTTP/1\.0#', $response);
93+
}
94+
6595
/** @test */
6696
public function testSelfSignedRejectsIfVerificationIsEnabled()
6797
{

0 commit comments

Comments
 (0)