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
4 changes: 3 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ if ($response !== null) {
echo $response->providerName;

if ($response->hasHtml()) {
echo $response->html; // Ready-to-use embed code
echo $response->html; // Raw remote provider HTML
}

if ($response->hasThumbnail()) {
Expand All @@ -455,6 +455,8 @@ $response = $discovery->fetch('https://example.com/oembed?url=...');
```

Discovered oEmbed endpoint URLs may be absolute, protocol-relative, root-relative, or path-relative. Relative endpoint URLs are resolved against the source page URL before they are fetched.
Only public `http` and `https` endpoint URLs are fetched; local/private IP endpoints and unsafe schemes are rejected.
The returned `html` field is raw remote provider HTML. Only render it for providers you trust, or sanitize it first.

The `OEmbedResponse` provides typed access to all oEmbed fields:

Expand Down
39 changes: 38 additions & 1 deletion src/OEmbed/OEmbedDiscovery.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ public function discoverEndpoint(string $url): ?string {
* @return \MediaEmbed\OEmbed\OEmbedResponse|null Response or null on failure.
*/
public function fetch(string $endpointUrl, ?int $maxWidth = null, ?int $maxHeight = null): ?OEmbedResponse {
if (!$this->isSafeEndpointUrl($endpointUrl)) {
return null;
}

$params = [];
if ($maxWidth !== null) {
$params['maxwidth'] = $maxWidth;
Expand Down Expand Up @@ -135,7 +139,12 @@ private function parseOEmbedLink(string $html, string $baseUrl): ?string {
// Decode HTML entities
$href = html_entity_decode($href, ENT_QUOTES | ENT_HTML5);

return $this->resolveEndpointUrl($href, $baseUrl);
$url = $this->resolveEndpointUrl($href, $baseUrl);
if (!$this->isSafeEndpointUrl($url)) {
return null;
}

return $url;
}

/**
Expand Down Expand Up @@ -174,4 +183,32 @@ private function resolveEndpointUrl(string $href, string $baseUrl): string {
return $authority . $directory . '/' . $href;
}

/**
* Check if an oEmbed endpoint URL is safe to fetch.
*
* @param string $url Endpoint URL.
* @return bool
*/
private function isSafeEndpointUrl(string $url): bool {
$parts = parse_url($url);
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
return false;
}

if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
return false;
}

$host = strtolower($parts['host']);
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
return false;
}

if (filter_var($host, FILTER_VALIDATE_IP)) {
return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}

return true;
}

}
34 changes: 34 additions & 0 deletions tests/OEmbed/OEmbedTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,28 @@ public function testOEmbedDiscoveryNoEndpoint(): void {
$this->assertNull($endpoint);
}

public function testOEmbedDiscoveryRejectsUnsafeEndpointScheme(): void {
$mockClient = $this->createStub(HttpClientInterface::class);
$mockClient->method('get')
->willReturn('<html><head><link rel="alternate" type="application/json+oembed" href="javascript:alert(1)" /></head></html>');

$discovery = new OEmbedDiscovery($mockClient);
$endpoint = $discovery->discoverEndpoint('https://example.com/page');

$this->assertNull($endpoint);
}

public function testOEmbedDiscoveryRejectsProtocolRelativeLocalhostEndpoint(): void {
$mockClient = $this->createStub(HttpClientInterface::class);
$mockClient->method('get')
->willReturn('<html><head><link rel="alternate" type="application/json+oembed" href="//localhost/oembed" /></head></html>');

$discovery = new OEmbedDiscovery($mockClient);
$endpoint = $discovery->discoverEndpoint('https://example.com/page');

$this->assertNull($endpoint);
}

public function testOEmbedDiscoveryFetch(): void {
$mockClient = $this->createStub(HttpClientInterface::class);
$mockClient->method('get')
Expand All @@ -144,6 +166,18 @@ public function testOEmbedDiscoveryFetch(): void {
$this->assertSame('Mock Video', $response->title);
}

public function testOEmbedDiscoveryFetchRejectsUnsafeEndpoint(): void {
$mockClient = $this->createMock(HttpClientInterface::class);
$mockClient->expects($this->never())
->method('get');

$discovery = new OEmbedDiscovery($mockClient);

$this->assertNull($discovery->fetch('file:///tmp/oembed.json'));
$this->assertNull($discovery->fetch('http://127.0.0.1/oembed'));
$this->assertNull($discovery->fetch('http://10.0.0.1/oembed'));
}

public function testOEmbedDiscoveryFetchWithDimensions(): void {
$mockClient = $this->createMock(HttpClientInterface::class);
$mockClient->expects($this->once())
Expand Down
Loading