Skip to content
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-30
### 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.3] - 2026-10-30
### Added
- Kick adapter. Channel pages embed `https://player.kick.com/<slug>` (16:9). Video pages read the title, thumbnail and author name from Kick's public API. Clips have no embed code because Kick does not document a clip player
Expand Down Expand Up @@ -333,9 +346,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.3...v4.5.4
[4.5.3]: https://github.com/php-embed/Embed/compare/v4.5.2...v4.5.3
[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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
]);
Expand Down Expand Up @@ -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:

Expand Down
11 changes: 7 additions & 4 deletions src/Http/CurlDispatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
33 changes: 32 additions & 1 deletion tests/CurlClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Loading