Skip to content
Open
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/).

## [Unreleased]
### Security
- JSON-LD remote contexts are never fetched. A page-supplied `@context` is not requested, so it cannot bypass the URL policy or follow redirects. schema.org contexts are resolved from a bundled vocabulary; any other remote context is skipped [#573]

## [4.5.0] - 2026-10-19
### Security
- Block requests to non-public addresses before they are sent, including redirects, page-declared oEmbed endpoints, adapter APIs, and meta-refresh targets. Private and loopback URLs are rejected unless `UrlPolicy::allowPrivateNetworks()` or `withAllowedHosts()` is used [#573]
Expand Down Expand Up @@ -318,6 +322,7 @@ Full library refactoring.
[#574]: https://github.com/oscarotero/Embed/issues/574
[#577]: https://github.com/oscarotero/Embed/issues/577

[Unreleased]: https://github.com/php-embed/Embed/compare/v4.5.0...HEAD
[4.5.0]: https://github.com/php-embed/Embed/compare/v4.4.19...v4.5.0
[4.4.19]: https://github.com/oscarotero/Embed/compare/v4.4.18...v4.4.19
[4.4.18]: https://github.com/oscarotero/Embed/compare/v4.4.17...v4.4.18
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ $oembed = $info->getOEmbed();

Another API available by default, used to extract info using the [JsonLD](https://www.w3.org/TR/json-ld/) schema.

Remote `@context` URLs are never fetched. schema.org contexts (`schema.org`, `www.schema.org`, and `schema.googleapis.com`, with or without a trailing slash, `.jsonld`, or `docs/jsonldcontext.json`) are read from a bundled vocabulary. Any other remote context is skipped and extraction continues with the rest of the page.

```php
//Get the linkedData object
$ld = $info->getLinkedData();
Expand Down Expand Up @@ -361,7 +363,7 @@ Note: The built-in detectors does not require settings. This feature is only for

## Security / SSRF protection

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.
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`.

Expand Down
119 changes: 119 additions & 0 deletions src/JsonLd/DocumentLoader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php
declare(strict_types = 1);

namespace Embed\JsonLd;

use ML\JsonLD\DocumentLoaderInterface;
use ML\JsonLD\Exception\JsonLdException;
use ML\JsonLD\Processor;
use ML\JsonLD\RemoteDocument;

/**
* JSON-LD document loader that never touches the network or the filesystem.
*
* LinkedData passes this as the documentLoader option. The global
* JsonLD::setDefaultDocumentLoader() is not used, so a host application
* keeps its own loader.
*
* schema.org context URLs are answered with a bundled vocabulary of
* {"@context":{"@vocab":"http://schema.org/"}}. Every other remote URL or
* path raises JsonLdException, which LinkedData already catches.
*/
final class DocumentLoader implements DocumentLoaderInterface
{
private const SCHEMA_CONTEXT = '{"@context":{"@vocab":"http://schema.org/"}}';

/** @var array<int, string> */
private const SCHEMA_HOSTS = [
'schema.org',
'www.schema.org',
'schema.googleapis.com',
];

/**
* @param string $url
*
* @throws JsonLdException
*
* @return RemoteDocument
*/
public function loadDocument($url)
{
$trimmed = trim($url);
if (isset($trimmed[0]) && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
return new RemoteDocument($url, Processor::parse($trimmed));
}

if ($this->isSchemaOrgContext($trimmed)) {
return new RemoteDocument($url, Processor::parse(self::SCHEMA_CONTEXT), 'application/ld+json');
}

throw new JsonLdException(
JsonLdException::LOADING_REMOTE_CONTEXT_FAILED,
'Remote JSON-LD documents are not loaded.'
);
}

private function isSchemaOrgContext(string $url): bool
{
$parts = parse_url($url);
if (!is_array($parts)) {
return false;
}

$scheme = $parts['scheme'] ?? null;
if (!is_string($scheme)) {
return false;
}
$scheme = strtolower($scheme);
if ($scheme !== 'http' && $scheme !== 'https') {
return false;
}

if (isset($parts['user']) || isset($parts['pass'])) {
return false;
}

if (isset($parts['port'])) {
$port = $parts['port'];
$defaultPort = $scheme === 'https' ? 443 : 80;
if ($port !== $defaultPort) {
return false;
}
}

$host = $parts['host'] ?? null;
if (!is_string($host) || !in_array(strtolower($host), self::SCHEMA_HOSTS, true)) {
return false;
}

return $this->isSchemaContextPath($parts['path'] ?? '');
}

private function isSchemaContextPath(string $path): bool
{
$normalized = strtolower(rtrim($path, '/'));
if ($this->isBundledContextPath($normalized)) {
return true;
}

$suffix = substr($normalized, -7);
if ($suffix !== '.jsonld') {
return false;
}

$withoutSuffix = substr($normalized, 0, -7);
if ($withoutSuffix === false) {
return false;
}

return $this->isBundledContextPath(rtrim($withoutSuffix, '/'));
}

private function isBundledContextPath(string $path): bool
{
return $path === ''
|| $path === '/docs/jsonldcontext.json'
|| $path === '/docs/jsonldcontext.jsonld';
}
}
18 changes: 15 additions & 3 deletions src/LinkedData.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

namespace Embed;

use Embed\JsonLd\DocumentLoader;
use Exception;
use ML\JsonLD\JsonLD;
use ML\JsonLD\Document as LdDocument;
use ML\JsonLD\DocumentInterface;
use ML\JsonLD\GraphInterface;
Expand Down Expand Up @@ -66,16 +66,28 @@ private function getGraph(?string $name = null): ?GraphInterface
if ($encoded === false) {
$encoded = '{}';
}
$this->document = LdDocument::load($encoded);
$this->document = self::loadJsonLd($encoded);
} catch (Throwable $throwable) {
$this->document = LdDocument::load('{}');
try {
$this->document = self::loadJsonLd('{}');
} catch (Throwable $ignored) {
$this->document = new LdDocument();
}

return null;
}
}

return $this->document->getGraph($name);
}

private static function loadJsonLd(string $json): DocumentInterface
{
return LdDocument::load($json, [
'documentLoader' => new DocumentLoader(),
]);
}

/**
* @return array<string, mixed>
*/
Expand Down
Loading
Loading