Skip to content

Commit 6005dcc

Browse files
committed
Support connecting to IPv6 servers via IP Address. PHP doesn't validate IPv6 addresses in subjectAlternateNames in certificates, so we must do it manually.
1 parent 69d3cba commit 6005dcc

File tree

3 files changed

+216
-42
lines changed

3 files changed

+216
-42
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"require": {
1414
"php": "^8.0",
1515
"react/http": "^1.6",
16-
"react/dns": "*"
16+
"react/dns": "*",
17+
"ext-openssl": "*"
1718
},
1819
"require-dev": {
1920
"phpunit/phpunit": "^9.5"

src/DohExecutor.php

Lines changed: 131 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace EdgeTelemetrics\React\Dns;
44

5+
use Exception;
6+
use InvalidArgumentException;
57
use Psr\Http\Message\ResponseInterface;
68
use React\Dns\Model\Message;
79
use React\Dns\Protocol\BinaryDumper;
@@ -12,8 +14,14 @@
1214
use React\EventLoop\LoopInterface;
1315
use React\Http\Browser;
1416
use React\Promise;
17+
use React\Promise\Deferred;
18+
use React\Socket\ConnectionInterface;
1519
use React\Socket\Connector;
1620
use RuntimeException;
21+
use function parse_url;
22+
use function strlen;
23+
use function strtolower;
24+
use function substr_count;
1725

1826
class DohExecutor implements ExecutorInterface {
1927

@@ -24,11 +32,15 @@ class DohExecutor implements ExecutorInterface {
2432

2533
private string $method;
2634

27-
private Browser $browser;
35+
private bool $ipv6address = false;
36+
37+
private Promise\PromiseInterface $browserResolution;
2838

2939
const METHOD_GET = 'get';
3040
const METHOD_POST = 'post';
3141

42+
const FINGERPRINT_HASH_METHOD = 'sha256';
43+
3244
/**
3345
* @param string $nameserver
3446
* @param ?LoopInterface $loop
@@ -40,58 +52,67 @@ public function __construct(string $nameserver, LoopInterface $loop = null, stri
4052
throw new RuntimeException('DNS over HTTPS support requires reactphp/http library'); //@codeCoverageIgnore
4153
}
4254

43-
if (!str_contains($nameserver, '[') && \substr_count($nameserver, ':') >= 2 && !str_contains($nameserver, '://')) {
55+
if (!str_contains($nameserver, '[') && substr_count($nameserver, ':') >= 2 && !str_contains($nameserver, '://')) {
4456
// several colons, but not enclosed in square brackets => enclose IPv6 address in square brackets
4557
$nameserver = '[' . $nameserver . ']';
4658
}
4759

48-
$parts = \parse_url((!str_contains($nameserver, '://') ? 'https://' : '') . $nameserver);
49-
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'https' || @\inet_pton(\trim($parts['host'], '[]')) === false) {
50-
throw new \InvalidArgumentException('Invalid nameserver address given');
60+
$parts = parse_url((!str_contains($nameserver, '://') ? 'https://' : '') . $nameserver);
61+
if (!isset($parts['scheme'], $parts['host']) || $parts['scheme'] !== 'https') {
62+
throw new InvalidArgumentException('Invalid nameserver address given');
63+
}
64+
65+
if (filter_var(trim($parts['host'], '[]'), FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
66+
$this->ipv6address = true;
5167
}
5268

53-
$method = \strtolower($method);
69+
$method = strtolower($method);
5470
if (!in_array($method, [self::METHOD_GET, self::METHOD_POST], true)) {
55-
throw new \InvalidArgumentException('Invalid HTTP request method given');
71+
throw new InvalidArgumentException('Invalid HTTP request method given');
5672
}
5773

5874
$this->nameserver = 'https://' . $parts['host'] . ':' . ($parts['port'] ?? 443 . '/dns-query');
5975
$this->loop = $loop ?: Loop::get();
6076
$this->parser = new Parser();
6177
$this->dumper = new BinaryDumper();
6278
$this->method = $method;
63-
$this->browser = (new Browser(new Connector(['tcp_nodelay' => true,]), $this->loop));
6479
}
6580

6681
public function query(Query $query)
6782
{
68-
$request = Message::createRequestForQuery($query);
69-
70-
$queryData = $this->dumper->toBinary($request);
71-
$length = \strlen($queryData);
72-
73-
if ($length > 0xffff) {
74-
return Promise\reject(new \RuntimeException(
75-
'DNS query for ' . $query->describe() . ' failed: Query too large for HTTPS transport'
76-
));
77-
}
78-
79-
if ($this->method === self::METHOD_GET) {
80-
$requestUrl = $this->nameserver . '?' . http_build_query(['dns' => $this->urlsafeBase64($queryData)]);
81-
$request = $this->browser->get($requestUrl);
82-
} else {
83-
$requestUrl = $this->nameserver;
84-
$request = $this->browser->post($requestUrl, [
85-
'accept' => 'application/dns-message',
86-
'content-type' => 'application/dns-message'
87-
], $queryData);
88-
}
89-
90-
return $request->then(function (ResponseInterface $response) {
91-
$response = $this->parser->parseMessage((string)$response->getBody());
92-
return Promise\resolve($response);
93-
}, function (\Exception $e) use ($query) {
94-
return Promise\reject(new \RuntimeException(
83+
return $this->getBrowser()->then(function($browser) use ($query) {
84+
$request = Message::createRequestForQuery($query);
85+
86+
$queryData = $this->dumper->toBinary($request);
87+
$length = strlen($queryData);
88+
89+
if ($length > 0xffff) {
90+
return Promise\reject(new RuntimeException(
91+
'DNS query for ' . $query->describe() . ' failed: Query too large for HTTPS transport'
92+
));
93+
}
94+
95+
if ($this->method === self::METHOD_GET) {
96+
$requestUrl = $this->nameserver . '?' . http_build_query(['dns' => $this->urlsafeBase64($queryData)]);
97+
$request = $browser->get($requestUrl);
98+
} else {
99+
$requestUrl = $this->nameserver;
100+
$request = $browser->post($requestUrl, [
101+
'accept' => 'application/dns-message',
102+
'content-type' => 'application/dns-message'
103+
], $queryData);
104+
}
105+
106+
return $request->then(function (ResponseInterface $response) {
107+
$response = $this->parser->parseMessage((string)$response->getBody());
108+
return Promise\resolve($response);
109+
}, function (Exception $e) use ($query) {
110+
return Promise\reject(new RuntimeException(
111+
'DNS query for ' . $query->describe() . ' failed: ' . $e->getMessage()
112+
));
113+
});
114+
}, function($e) use ($query) {
115+
return Promise\reject(new RuntimeException(
95116
'DNS query for ' . $query->describe() . ' failed: ' . $e->getMessage()
96117
));
97118
});
@@ -104,10 +125,82 @@ public function query(Query $query)
104125
private function urlsafeBase64(string $data) : string {
105126
// @codeCoverageIgnoreStart
106127
if (function_exists('sodium_bin2base64')) {
107-
return sodium_bin2base64($data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
108-
} else {
109-
return rtrim( strtr( base64_encode( $data ), '+/', '-_'), '=');
128+
try {
129+
return sodium_bin2base64($data, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
130+
} catch (\SodiumException $ex) { /* Allow fallthrough to non sodium method */}
110131
}
132+
return rtrim( strtr( base64_encode( $data ), '+/', '-_'), '=');
111133
//@codeCoverageIgnoreEnd
112134
}
135+
136+
private function getBrowser() : Promise\PromiseInterface {
137+
if (!isset($this->browserResolution)) {
138+
$deferred = new Deferred();
139+
$this->browserResolution = $deferred->promise();
140+
if ($this->ipv6address) {
141+
// PHP does not validate IPv6 addresses contained in the SAN fields of a certificate
142+
// To support IPv6 we download the certificate on the first connect and manually verify our nameserver IPv6 IP
143+
// is listed in the SAN fields. We then construct a Browser instance with verify_peer_name set to false but with the peer_fingerprint set to our verified certificate.
144+
// This doesn't always work because the server may use different front end certificates (SIGH!)
145+
$address = str_replace('https://', 'tls://', $this->nameserver);
146+
$connector = new Connector([
147+
'tcp' => [
148+
'tcp_nodelay' => true,
149+
],
150+
'tls' => [
151+
'verify_peer_name' => false,
152+
'capture_peer_cert' => true
153+
],
154+
'dns' => false,
155+
], $this->loop);
156+
$connector->connect($address)->then(function (ConnectionInterface $connection) use ($deferred) {
157+
$response = stream_context_get_params($connection->stream); //Using @internal stream
158+
$connection->end();
159+
$certificatePem = $response['options']['ssl']['peer_certificate'];
160+
161+
$certificateFields = openssl_x509_parse($certificatePem);
162+
$additionalDomains = explode(', ', $certificateFields['extensions']['subjectAltName'] ?? '');
163+
164+
$ip = inet_pton(trim(parse_url($this->nameserver, PHP_URL_HOST), '[]'));
165+
if ($ip !== false) {
166+
foreach ($additionalDomains as $subAltName) {
167+
$subAltName = trim(strtolower($subAltName));
168+
if (str_starts_with($subAltName, 'ip address:')) {
169+
$compare = inet_pton(str_replace('ip address:', '', $subAltName));
170+
if ($compare === $ip) {
171+
$fingerprint = openssl_x509_fingerprint($certificatePem, self::FINGERPRINT_HASH_METHOD);
172+
$browser = (new Browser(new Connector([
173+
'tcp' => [
174+
'tcp_nodelay' => true,
175+
],
176+
'tls' => [
177+
'verify_peer_name' => false,
178+
'peer_fingerprint'=>[
179+
self::FINGERPRINT_HASH_METHOD => $fingerprint,
180+
],
181+
],
182+
], $this->loop), $this->loop));
183+
$deferred->resolve($browser);
184+
return;
185+
}
186+
}
187+
}
188+
}
189+
$deferred->reject(new RuntimeException('IPv6 IP Address Connection Failed. Unable to Validate Peer Certificate'));
190+
191+
}, function($ex) use ($deferred) {
192+
$deferred->reject(new RuntimeException('IPv6 IP Address Connection Failed. ' . $ex->getMessage()));
193+
});
194+
} else {
195+
$browser = (new Browser(new Connector([
196+
'tcp' => [
197+
'tcp_nodelay' => true,
198+
],
199+
]
200+
), $this->loop));
201+
$deferred->resolve($browser);
202+
}
203+
}
204+
return $this->browserResolution;
205+
}
113206
}

tests/FunctionalTests.php

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
*/
1414
class FunctionalTests extends TestCase
1515
{
16-
public function testResolveGoogleViaPostResolves()
16+
public function testResolveCloudflareViaPostResolves()
1717
{
1818
$executor = new DohExecutor('https://1.1.1.1/dns-query', null,DohExecutor::METHOD_POST);
1919
$query = new Query('one.one.one.one', Message::TYPE_A, Message::CLASS_IN);
@@ -30,7 +30,7 @@ public function testResolveGoogleViaPostResolves()
3030
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
3131
}
3232

33-
public function testResolveGoogleViaGetResolves()
33+
public function testResolveCloudflareViaGetResolves()
3434
{
3535
$executor = new DohExecutor('https://1.1.1.1/dns-query', null,DohExecutor::METHOD_GET);
3636
$query = new Query('one.one.one.one', Message::TYPE_A, Message::CLASS_IN);
@@ -47,6 +47,79 @@ public function testResolveGoogleViaGetResolves()
4747
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
4848
}
4949

50+
public function testResolveCloudflareHostnameViaGetResolves()
51+
{
52+
$executor = new DohExecutor('https://one.one.one.one/dns-query', null,DohExecutor::METHOD_GET);
53+
$query = new Query('one.one.one.one', Message::TYPE_A, Message::CLASS_IN);
54+
$promise = $executor->query($query);
55+
56+
$answer = null;
57+
$promise->then(function ($message) use (&$answer) {
58+
$answer = $message;
59+
});
60+
61+
Loop::run();
62+
63+
$this->assertNotNull($answer);
64+
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
65+
}
66+
67+
public function testResolveSecondQueryReusesConnection()
68+
{
69+
$executor = new DohExecutor('https://one.one.one.one/dns-query', null,DohExecutor::METHOD_GET);
70+
$query = new Query('one.one.one.one', Message::TYPE_A, Message::CLASS_IN);
71+
$promise1 = $executor->query($query);
72+
$promise2 = $executor->query($query);
73+
74+
$answer = null;
75+
$promise1->then(function ($message) use (&$answer) {
76+
$answer = $message;
77+
});
78+
79+
$promise2->then(function ($message) use (&$answer) {
80+
$answer = $message;
81+
});
82+
83+
Loop::run();
84+
85+
$this->assertNotNull($answer);
86+
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
87+
}
88+
89+
public function testResolveGoogleViaIPv6HostResolves()
90+
{
91+
$executor = new DohExecutor('https://dns64.dns.google/dns-query', null,DohExecutor::METHOD_GET);
92+
$query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN);
93+
$promise = $executor->query($query);
94+
95+
$answer = null;
96+
$promise->then(function ($message) use (&$answer) {
97+
$answer = $message;
98+
}, function($reason) { echo $reason->getMessage();});
99+
100+
Loop::run();
101+
102+
$this->assertNotNull($answer);
103+
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
104+
}
105+
106+
public function testResolveGoogleViaIPv6IpResolves()
107+
{
108+
$executor = new DohExecutor('https://[2001:4860:4860::8888]/dns-query', null,DohExecutor::METHOD_GET);
109+
$query = new Query('google.com', Message::TYPE_A, Message::CLASS_IN);
110+
$promise = $executor->query($query);
111+
112+
$answer = null;
113+
$promise->then(function ($message) use (&$answer) {
114+
$answer = $message;
115+
}, function($reason) { echo $reason->getMessage();});
116+
117+
Loop::run();
118+
119+
$this->assertNotNull($answer);
120+
$this->assertEquals(Message::RCODE_OK, $answer->rcode);
121+
}
122+
50123
public function testResolveInvalidRejects()
51124
{
52125
$executor = new DohExecutor('https://1.1.1.1/dns-query');
@@ -84,7 +157,7 @@ public function testResolveToInvalidServerRejects()
84157

85158
public function testQueryRejectsIfMessageExceedsMaximumMessageSize()
86159
{
87-
$executor = $executor = new DohExecutor('https://127.0.0.1:0/dns-query');
160+
$executor = new DohExecutor('https://127.0.0.1:0/dns-query');
88161

89162
$query = new Query('google.' . str_repeat('.com', 60000), Message::TYPE_A, Message::CLASS_IN);
90163
$promise = $executor->query($query);
@@ -105,4 +178,11 @@ public function testResolveViaInvalidHttpMethodThrows()
105178

106179
new DohExecutor('https://1.1.1.1/dns-query', null, 'put');
107180
}
181+
182+
public function testInvalidNameserverThrows()
183+
{
184+
$this->expectException(\InvalidArgumentException::class);
185+
186+
new DohExecutor('http://1.1.1.1/dns-query');
187+
}
108188
}

0 commit comments

Comments
 (0)