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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ 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.6.0] - 2026-10-30
### Added
- `mastodon:iframe` setting (off by default). Mastodon oEmbed html (`blockquote.mastodon-embed` with `data-embed-url`) becomes a sandboxed `<iframe>` to the post's `/embed` page, so no `embed.js` is needed. Only same-host `https` embed URLs are used

## [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]
Expand Down Expand Up @@ -350,6 +354,7 @@ Full library refactoring.
[#574]: https://github.com/oscarotero/Embed/issues/574
[#577]: https://github.com/oscarotero/Embed/issues/577

[4.6.0]: https://github.com/php-embed/Embed/compare/v4.5.4...v4.6.0
[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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,10 +348,13 @@ $embed->setSettings([
'facebook:token' => '1234|5678', //Required to embed content from Facebook
'instagram:token' => '1234|5678', //Required to embed content from Instagram
'twitter:token' => 'asdf', //Improve the data from twitter
'mastodon:iframe' => true, //Embed Mastodon posts as an iframe instead of a blockquote + embed.js (default: false)
]);
$info = $embed->get($url);
```

With `mastodon:iframe`, the oEmbed html of any Mastodon server (`<blockquote class="mastodon-embed" data-embed-url="...">` plus `embed.js`) is replaced with an `<iframe>` pointing to its `data-embed-url`, 400px wide, sandboxed and with `allowfullscreen`. It only applies when the embed URL is `https`, ends in `/embed` and is on the same host as the post. Mastodon does not report a height, so `$info->code->height` is `null`; without `embed.js` the iframe does not resize to its content, so set a height in your CSS.

Note: The built-in detectors does not require settings. This feature is only for convenience if you create a specific detector that requires settings.

## Security / SSRF protection
Expand Down
53 changes: 53 additions & 0 deletions src/Detectors/Code.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,66 @@ private function detectFromEmbed(): ?EmbedCode
return $this->detectFromOEmbedPhoto();
}

if ($this->extractor->getSetting('mastodon:iframe') === true) {
$iframe = $this->detectMastodonIframe($html, $oembed->int('width'));

if ($iframe !== null) {
return $iframe;
}
}

return new EmbedCode(
$html,
$oembed->int('width'),
$oembed->int('height')
);
}

/**
* Mastodon's oEmbed html is a blockquote that needs embed.js to become
* an iframe. With the "mastodon:iframe" setting, build the iframe directly
* from its data-embed-url, only when it points to the same host.
*/
private function detectMastodonIframe(string $html, ?int $width): ?EmbedCode
{
if (preg_match('/<blockquote\b[^>]*\bclass="[^"]*\bmastodon-embed\b[^"]*"[^>]*>/i', $html, $tag) !== 1) {
return null;
}

if (preg_match('/\bdata-embed-url="([^"]+)"/i', $tag[0], $match) !== 1) {
return null;
}

$src = html_entity_decode($match[1], ENT_QUOTES | ENT_HTML5);
$parts = parse_url($src);

if (
!is_array($parts)
|| ($parts['scheme'] ?? '') !== 'https'
|| !isset($parts['host'])
|| strtolower($parts['host']) !== strtolower($this->extractor->getUri()->getHost())
|| preg_match('#/embed$#', $parts['path'] ?? '') !== 1
) {
return null;
}

if ($width === null || $width <= 0) {
$width = 400;
}

$code = html('iframe', [
'src' => $src,
'class' => 'mastodon-embed',
'style' => 'max-width: 100%; border: 0',
'width' => $width,
'sandbox' => 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox',
'allowfullscreen' => 'allowfullscreen',
'loading' => 'lazy',
]);

return new EmbedCode($code, $width, null);
}

private function detectFromOEmbedPhoto(): ?EmbedCode
{
$oembed = $this->extractor->getOEmbed();
Expand Down
137 changes: 137 additions & 0 deletions tests/MastodonIframeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php
declare(strict_types = 1);

namespace Embed\Tests;

use Embed\Embed;
use Embed\Http\Crawler;
use Embed\Http\UrlPolicy;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

/**
* The "mastodon:iframe" setting turns Mastodon's oEmbed blockquote into an iframe.
*/
class MastodonIframeTest extends TestCase
{
private const SANDBOX = 'allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox';

/**
* @return array<string, array{string, string}>
*/
public static function recordedPosts(): array
{
return [
'fosstodon' => [
'https://fosstodon.org/@AkaSci/117514634840174369',
'https://fosstodon.org/@AkaSci/117514634840174369/embed',
],
'hachyderm' => [
'https://hachyderm.io/@briancmoses/117514657412674435',
'https://hachyderm.io/@briancmoses/117514657412674435/embed',
],
];
}

/**
* @dataProvider recordedPosts
*/
public function testRecordedPostWithSettingIsAnIframe(string $url, string $embedUrl): void
{
$code = $this->recorded(['mastodon:iframe' => true])->get($url)->code;

$this->assertNotNull($code);
$this->assertSame(
'<iframe src="'.$embedUrl.'" class="mastodon-embed" style="max-width: 100%; border: 0" width="400" sandbox="'.self::SANDBOX.'" allowfullscreen="allowfullscreen" loading="lazy"></iframe>',
$code->html
);
$this->assertSame(400, $code->width);
$this->assertNull($code->height);
}

/**
* @dataProvider recordedPosts
*/
public function testRecordedPostWithoutSettingKeepsTheBlockquote(string $url, string $embedUrl): void
{
$code = $this->recorded([])->get($url)->code;

$this->assertNotNull($code);
$this->assertStringStartsWith('<blockquote class="mastodon-embed" data-embed-url="'.$embedUrl.'"', $code->html);
$this->assertStringContainsString('embed.js', $code->html);
}

public function testEmbedUrlOnAnotherHostIsIgnored(): void
{
$code = $this->fake('<blockquote class="mastodon-embed" data-embed-url="https://evil.test/@a/1/embed">x</blockquote>')
->get('https://social.test/@a/1')->code;

$this->assertNotNull($code);
$this->assertStringStartsWith('<blockquote', $code->html);
}

public function testNonHttpsEmbedUrlIsIgnored(): void
{
$code = $this->fake('<blockquote class="mastodon-embed" data-embed-url="http://social.test/@a/1/embed">x</blockquote>')
->get('https://social.test/@a/1')->code;

$this->assertNotNull($code);
$this->assertStringStartsWith('<blockquote', $code->html);
}

public function testOtherOEmbedHtmlIsUntouched(): void
{
$code = $this->fake('<blockquote class="twitter-tweet">x</blockquote>')
->get('https://social.test/@a/1')->code;

$this->assertNotNull($code);
$this->assertSame('<blockquote class="twitter-tweet">x</blockquote>', $code->html);
}

/**
* @param array<string, mixed> $settings
*/
private function recorded(array $settings): Embed
{
$client = new FileClient(__DIR__.'/cache');
$client->setMode(-1);
$crawler = new Crawler($client);
$crawler->setUrlPolicy(UrlPolicy::default()->withResolver(static function (string $host): array {
return ['8.8.8.8'];
}));

$embed = new Embed($crawler);
$embed->setSettings($settings);

return $embed;
}

private function fake(string $html): Embed
{
$json = (string) json_encode(['type' => 'rich', 'version' => '1.0', 'html' => $html, 'width' => 400], JSON_UNESCAPED_SLASHES);
$page = '<!DOCTYPE html><html><head><title>Post</title>'
.'<link rel="alternate" type="application/json+oembed" href="https://social.test/api/oembed?url=post">'
.'</head><body></body></html>';

$client = new RecordingClient(static function (RequestInterface $request) use ($json, $page): ResponseInterface {
$factory = new Psr17Factory();
$isOEmbed = $request->getUri()->getPath() === '/api/oembed';

return $factory->createResponse(200)
->withHeader('Content-Type', $isOEmbed ? 'application/json' : 'text/html')
->withBody($factory->createStream($isOEmbed ? $json : $page));
});

$crawler = new Crawler($client);
$crawler->setUrlPolicy(UrlPolicy::default()->withResolver(static function (string $host): array {
return ['1.1.1.1'];
}));

$embed = new Embed($crawler);
$embed->setSettings(['mastodon:iframe' => true]);

return $embed;
}
}
10 changes: 10 additions & 0 deletions tests/PagesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,14 @@ public function testBBCNews()
{
$this->assertEmbed('https://www.bbc.co.uk/news/uk-54222286');
}

public function testMastodonFosstodon()
{
$this->assertEmbed('https://fosstodon.org/@AkaSci/117514634840174369');
}

public function testMastodonHachyderm()
{
$this->assertEmbed('https://hachyderm.io/@briancmoses/117514657412674435');
}
}
Loading
Loading