2 Commits

Author SHA1 Message Date
  Aaron Parecki 907552b1c7 Resolve hosts with getaddrinfo so hosts-file IPv6 entries are found 2 days ago
  Aaron Parecki 10b7d10d27 Pin every resolved address in safe mode, not just the last 2 days ago
6 changed files with 53 additions and 6 deletions
Unified View
  1. +5
    -3
      src/p3k/HTTP.php
  2. +12
    -0
      src/p3k/HTTP/Guard.php
  3. +2
    -2
      src/p3k/HTTP/Pinnable.php
  4. +14
    -0
      tests/CurlPinningTest.php
  5. +19
    -0
      tests/GuardTest.php
  6. +1
    -1
      tests/SafeModeTest.php

+ 5
- 3
src/p3k/HTTP.php View File

@ -103,9 +103,11 @@ class HTTP {
$pinnable = $this->_transport instanceof HTTP\Pinnable; $pinnable = $this->_transport instanceof HTTP\Pinnable;
if($pinnable) { if($pinnable) {
$this->_transport->pin_addresses(array_map(function($address) use($check) {
return $check['host'] . ':' . $check['port'] . ':' . (strpos($address, ':') !== false ? '[' . $address . ']' : $address);
}, $check['addresses']));
// One entry listing every address: curl keeps only the last entry for
// a given host and port, which would drop all but one address
$this->_transport->pin_addresses([$check['host'] . ':' . $check['port'] . ':' . implode(',', array_map(function($address) {
return strpos($address, ':') !== false ? '[' . $address . ']' : $address;
}, $check['addresses']))]);
} }
try { try {
$response = $this->_build_response($this->_send($method, $url, $body, $headers)); $response = $this->_build_response($this->_send($method, $url, $body, $headers));

+ 12
- 0
src/p3k/HTTP/Guard.php View File

@ -91,6 +91,18 @@ class Guard {
/** Every IPv4 and IPv6 address the system resolver knows for a host. */ /** Every IPv4 and IPv6 address the system resolver knows for a host. */
public static function resolve($host) { public static function resolve($host) {
// getaddrinfo, as curl itself uses, sees both address families and the
// hosts file. Without ext-sockets, fall back to asking for each family
// separately, which misses IPv6 entries in the hosts file such as ::1.
if(function_exists('socket_addrinfo_lookup')) {
$addresses = [];
foreach(@socket_addrinfo_lookup($host, null, ['ai_socktype' => SOCK_STREAM]) ?: [] as $info) {
$address = socket_addrinfo_explain($info)['ai_addr'];
$addresses[] = $address['sin6_addr'] ?? $address['sin_addr'];
}
return array_values(array_unique($addresses));
}
$addresses = gethostbynamel($host) ?: []; $addresses = gethostbynamel($host) ?: [];
$records = @dns_get_record($host, DNS_AAAA); $records = @dns_get_record($host, DNS_AAAA);
foreach($records ?: [] as $record) { foreach($records ?: [] as $record) {

+ 2
- 2
src/p3k/HTTP/Pinnable.php View File

@ -9,8 +9,8 @@ namespace p3k\HTTP;
interface Pinnable { interface Pinnable {
/** /**
* @param array|null $resolve "host:port:address" entries, as for
* CURLOPT_RESOLVE; null lifts the restriction.
* @param array|null $resolve "host:port:address[,address...]" entries, as
* for CURLOPT_RESOLVE; null lifts the restriction.
*/ */
public function pin_addresses($resolve); public function pin_addresses($resolve);

+ 14
- 0
tests/CurlPinningTest.php View File

@ -44,6 +44,20 @@ class CurlPinningTest extends TestCase {
$this->assertSame('host=pinned.example:' . self::$port, $response['body']); $this->assertSame('host=pinned.example:' . self::$port, $response['body']);
} }
// curl keeps only the last CURLOPT_RESOLVE entry for a host and port, so
// every resolved address has to go in a single entry. Otherwise a host with
// both IPv6 and IPv4 addresses is only tried on the last one.
public function testTriesEveryPinnedAddress() {
$http = new HTTP('test');
$http->set_safe_mode(true, ['127.0.0.0/8'], function($host) {
// Nothing listens on 127.0.0.3, so this only succeeds if curl can fall back to 127.0.0.1
return $host === 'pinned.example' ? ['127.0.0.1', '127.0.0.3'] : [];
});
$response = $http->get('http://pinned.example:' . self::$port . '/');
$this->assertSame(200, $response['code']);
$this->assertSame('host=pinned.example:' . self::$port, $response['body']);
}
public function testRedirectsAreCheckedHopByHop() { public function testRedirectsAreCheckedHopByHop() {
$port = self::$port; $port = self::$port;
$response = $this->http()->get("http://pinned.example:$port/?to=" . rawurlencode("http://127.0.0.1:$port/")); $response = $this->http()->get("http://pinned.example:$port/?to=" . rawurlencode("http://127.0.0.1:$port/"));

+ 19
- 0
tests/GuardTest.php View File

@ -64,6 +64,25 @@ class GuardTest extends TestCase {
$this->assertSame(8080, $result['port']); $this->assertSame(8080, $result['port']);
} }
// Uses the real system resolver: localhost is in every hosts file, and the
// IPv6 entry is only found through getaddrinfo, not a DNS AAAA query.
public function testSystemResolverReadsTheHostsFile() {
if(!function_exists('socket_addrinfo_lookup'))
$this->markTestSkipped('ext-sockets is not available');
$expected = [];
foreach(socket_addrinfo_lookup('localhost', null, ['ai_socktype' => SOCK_STREAM]) as $info) {
$address = socket_addrinfo_explain($info)['ai_addr'];
$expected[] = $address['sin6_addr'] ?? $address['sin_addr'];
}
$addresses = Guard::resolve('localhost');
$this->assertContains('127.0.0.1', $addresses);
foreach(array_unique($expected) as $address)
$this->assertContains($address, $addresses);
$this->assertSame([], Guard::resolve('nonexistent.invalid'));
}
public function testUnresolvableHost() { public function testUnresolvableHost() {
$this->assertSame('dns_error', self::guard()->check('https://nowhere.example/')['error']); $this->assertSame('dns_error', self::guard()->check('https://nowhere.example/')['error']);
} }

+ 1
- 1
tests/SafeModeTest.php View File

@ -33,7 +33,7 @@ class SafeModeTest extends TestCase {
$http = $this->http(['https://b.example/' => [200, '', 'ok']], $transport); $http = $this->http(['https://b.example/' => [200, '', 'ok']], $transport);
$response = $http->get('https://b.example/'); $response = $http->get('https://b.example/');
$this->assertSame(200, $response['code']); $this->assertSame(200, $response['code']);
$this->assertSame(['b.example:443:93.184.216.35', 'b.example:443:[2606:2800:220:1::]'], $transport->requests[0]['pinned']);
$this->assertSame(['b.example:443:93.184.216.35,[2606:2800:220:1::]'], $transport->requests[0]['pinned']);
$this->assertSame(0, $transport->max_redirects); $this->assertSame(0, $transport->max_redirects);
} }

Loading…
Cancel
Save