From def2f4e6f66770154854ce846af9953767e99707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20de=20Oliveira?= Date: Tue, 27 Oct 2026 16:59:06 -0400 Subject: [PATCH] Verify TLS certificates and hostnames by default (fixes #575) --- CHANGELOG.md | 15 +++++++++++++++ README.md | 6 +++--- src/Http/CurlDispatcher.php | 11 +++++++---- tests/CurlClientTest.php | 33 ++++++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab7191f..4b7d6828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [4.5.4] - 2026-10-27 +### Security +- TLS verification is on by default. `CurlClient` now sends `CURLOPT_SSL_VERIFYPEER` true and `CURLOPT_SSL_VERIFYHOST` 2 unless told otherwise, so self-signed, expired and wrong-host certificates are rejected (curl error 60). Before, both checks were off when the settings were missing [#575] +- A `null` `ssl_verify_peer` or `ssl_verify_host` now means the default (verification on). Explicit `0`, `false`, `'0'` and `''` still disable it + +To disable verification, which is not recommended, pass the existing settings: + +```php +$client = new Embed\Http\CurlClient(); +$client->setSettings(['ssl_verify_peer' => false, 'ssl_verify_host' => 0]); +$embed = new Embed\Embed(new Embed\Http\Crawler($client)); +``` + ## [4.5.2] - 2026-10-27 ### Added - oEmbed responses of type `photo` without `html` now produce an `` embed code from their `url`, `width` and `height` [#584] @@ -324,9 +337,11 @@ Full library refactoring. [#572]: https://github.com/oscarotero/Embed/issues/572 [#573]: https://github.com/oscarotero/Embed/issues/573 [#584]: https://github.com/php-embed/Embed/issues/584 +[#575]: https://github.com/php-embed/Embed/issues/575 [#574]: https://github.com/oscarotero/Embed/issues/574 [#577]: https://github.com/oscarotero/Embed/issues/577 +[4.5.4]: https://github.com/php-embed/Embed/compare/v4.5.2...v4.5.4 [4.5.2]: https://github.com/php-embed/Embed/compare/v4.5.1...v4.5.2 [4.5.1]: https://github.com/php-embed/Embed/compare/v4.5.0...v4.5.1 [4.5.0]: https://github.com/php-embed/Embed/compare/v4.4.19...v4.5.0 diff --git a/README.md b/README.md index 7e6679d8..fb5c8186 100644 --- a/README.md +++ b/README.md @@ -327,8 +327,8 @@ $client->setSettings([ 'max_redirs' => 3, // redirects to follow; a negative value means up to 50 'connect_timeout' => 2, // per connection, see CURLOPT_CONNECTTIMEOUT 'timeout' => 2, // total seconds for the request, including redirects - 'ssl_verify_host' => 2, // 0 disables hostname checks; any other value enables them - 'ssl_verify_peer' => 1, // falsy disables certificate checks; any other value enables them + 'ssl_verify_host' => 2, // default 2; 0 disables hostname checks, any other value enables them + 'ssl_verify_peer' => true, // default true; falsy disables certificate checks, any other value enables them 'follow_location' => true, // false returns the 3xx; each hop is checked 'user_agent' => 'Mozilla', // see CURLOPT_USERAGENT ]); @@ -358,7 +358,7 @@ Note: The built-in detectors does not require settings. This feature is only for The default HTTP client refuses to connect to non-public addresses. That covers the URL you pass to `get()` / `getMulti()` and every later request Embed makes itself (redirects, oEmbed endpoints declared by the page, adapter APIs, and meta-refresh targets). A hostname is allowed only when every IPv4 address it resolves to is public. Loopback, private, link-local, CGNAT, documentation, and multicast ranges are rejected. IPv4-mapped IPv6, NAT64, and 6to4 are rejected when the embedded IPv4 address is not public. The Teredo prefix `2001::/32` is rejected as a whole, not by reading an address out of it. Only `http` and `https` are allowed, and redirects are followed inside Embed so each hop is checked the same way. JSON-LD `@context` URLs are not requested at all. -`follow_location` set to false still returns the 3xx response. `max_redirs` is still how many redirects are followed (10 by default). A negative value, which curl treated as unlimited, follows at most 50. `timeout` is the total time for the request and its redirects; `connect_timeout` is still per connection. `ssl_verify_host` of `0` disables hostname checks, and any other value enables them, as curl does when it receives `1` or `true`. `ssl_verify_peer` is off only for a falsy value (`0`, `false`, `'0'`, `''`); `1`, `2`, `'true'` and `'yes'` keep certificate checks on. Reaching the redirect limit still fails with curl error 47. The message is `Number of redirects hit maximum amount`. +`follow_location` set to false still returns the 3xx response. `max_redirs` is still how many redirects are followed (10 by default). A negative value, which curl treated as unlimited, follows at most 50. `timeout` is the total time for the request and its redirects; `connect_timeout` is still per connection. TLS certificates and hostnames are verified by default. `ssl_verify_host` of `0` disables hostname checks, and any other value enables them, as curl does when it receives `1` or `true`. `ssl_verify_peer` is off only for a falsy value (`0`, `false`, `'0'`, `''`); `1`, `2`, `'true'` and `'yes'` keep certificate checks on. A missing or `null` value keeps verification on. Reaching the redirect limit still fails with curl error 47. The message is `Number of redirects hit maximum amount`. This is a behavior change: a URL that used to be fetched from an intranet now throws `Embed\Http\BlockedRequestException`. To opt back in: diff --git a/src/Http/CurlDispatcher.php b/src/Http/CurlDispatcher.php index 7f9eec3b..14175c48 100644 --- a/src/Http/CurlDispatcher.php +++ b/src/Http/CurlDispatcher.php @@ -772,14 +772,15 @@ private function settingBool(string $key, bool $default): bool } /** - * libcurl accepts only 0 and 2. PHP turns 1 into 2 and emits a notice, + * Hostname verification is on (2) unless disabled explicitly with 0, + * false, "0" or "". libcurl accepts only 0 and 2. PHP turns 1 into 2 and emits a notice, * which is what a truthy setting used to do when it was passed through. * * @return 0|2 */ private function sslVerifyHost(): int { - $value = $this->settings['ssl_verify_host'] ?? 0; + $value = $this->settings['ssl_verify_host'] ?? 2; if ($value === false || $value === 0 || $value === 0.0 || $value === '' || $value === '0') { return 0; } @@ -823,12 +824,14 @@ private function applyTimeout(): void } /** - * Same rule as a value passed straight to curl: only a falsy setting + * Verification is on unless disabled explicitly. Same rule as a value + * passed straight to curl: only a falsy setting (0, false, "0", "") * disables certificate verification. 2, "true" and "yes" stay enabled. + * A missing or null setting keeps it enabled. */ private function sslVerifyPeer(): bool { - $value = $this->settings['ssl_verify_peer'] ?? false; + $value = $this->settings['ssl_verify_peer'] ?? true; if ($value === false || $value === 0 || $value === 0.0 || $value === '' || $value === '0' || $value === []) { return false; } diff --git a/tests/CurlClientTest.php b/tests/CurlClientTest.php index 3b8fe605..b1811ed1 100644 --- a/tests/CurlClientTest.php +++ b/tests/CurlClientTest.php @@ -248,6 +248,37 @@ public function testTimeoutIsABudgetAcrossRedirects(): void $this->assertSame(200, $within->getStatusCode()); } + public function testSslVerificationIsOnByDefault(): void + { + $tls = $this->startTlsServer(); + + try { + $url = 'https://allowed.test:'.$tls['port'].'/'; + $cases = [ + 'no settings' => [], + 'null settings' => ['ssl_verify_host' => null, 'ssl_verify_peer' => null], + 'only host disabled' => ['ssl_verify_host' => 0], + ]; + + foreach ($cases as $name => $settings) { + try { + $this->client($settings)->sendRequest($this->request($url)); + $this->fail('A self-signed certificate must be rejected with '.$name); + } catch (NetworkException $exception) { + $this->assertSame(60, $exception->getCode(), $name); + } + } + + $response = $this->client([ + 'ssl_verify_host' => 0, + 'ssl_verify_peer' => false, + ])->sendRequest($this->request($url)); + $this->assertSame(200, $response->getStatusCode(), 'explicitly disabled'); + } finally { + $this->stopTlsServer($tls); + } + } + public function testSslVerifyHostTruthyValuesKeepHostnameChecks(): void { $tls = $this->startTlsServer(); @@ -300,7 +331,7 @@ public function testSslVerifyPeerFalsyValuesAreTheOnlyOnesDisabled(): void try { $url = 'https://allowed.test:'.$tls['port'].'/'; - foreach ([0, false, '0', '', null] as $disabled) { + foreach ([0, false, '0', ''] as $disabled) { $response = $this->client([ 'ssl_verify_host' => 0, 'ssl_verify_peer' => $disabled,