From 576aa0e5068fc99410bc69c2196de02aaf661f9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADtor=20de=20Oliveira?= Date: Mon, 19 Oct 2026 23:43:18 +0000 Subject: [PATCH] Do not fetch remote JSON-LD contexts Page-supplied @context URLs were loaded with file_get_contents, which bypasses the crawler and the URL policy and follows redirects. Pass an offline document loader that serves a bundled schema.org vocabulary and refuses every other remote document. --- CHANGELOG.md | 5 + README.md | 4 +- src/JsonLd/DocumentLoader.php | 119 +++++++++++++++ src/LinkedData.php | 18 ++- tests/JsonLdRemoteContextTest.php | 237 ++++++++++++++++++++++++++++++ tests/SchemaOrgContext.php | 137 ----------------- tests/bootstrap.php | 2 - 7 files changed, 379 insertions(+), 143 deletions(-) create mode 100644 src/JsonLd/DocumentLoader.php create mode 100644 tests/JsonLdRemoteContextTest.php delete mode 100644 tests/SchemaOrgContext.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 428cdc7c..bd5d5f29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] @@ -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 diff --git a/README.md b/README.md index e8173ef5..5ac6b2ed 100644 --- a/README.md +++ b/README.md @@ -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(); @@ -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`. diff --git a/src/JsonLd/DocumentLoader.php b/src/JsonLd/DocumentLoader.php new file mode 100644 index 00000000..df995881 --- /dev/null +++ b/src/JsonLd/DocumentLoader.php @@ -0,0 +1,119 @@ + */ + 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'; + } +} diff --git a/src/LinkedData.php b/src/LinkedData.php index 91876654..b71eab6e 100644 --- a/src/LinkedData.php +++ b/src/LinkedData.php @@ -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; @@ -66,9 +66,14 @@ 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; } } @@ -76,6 +81,13 @@ private function getGraph(?string $name = null): ?GraphInterface return $this->document->getGraph($name); } + private static function loadJsonLd(string $json): DocumentInterface + { + return LdDocument::load($json, [ + 'documentLoader' => new DocumentLoader(), + ]); + } + /** * @return array */ diff --git a/tests/JsonLdRemoteContextTest.php b/tests/JsonLdRemoteContextTest.php new file mode 100644 index 00000000..bc6ebdb7 --- /dev/null +++ b/tests/JsonLdRemoteContextTest.php @@ -0,0 +1,237 @@ +stop(); + } + + protected function setUp(): void + { + self::$server->clearLog(); + } + + public function testLoopbackContextIsNotRequested(): void + { + $context = 'http://127.0.0.1:'.self::$server->port().'/go?to=/secret'; + $client = $this->htmlClient($this->page($context)); + + $info = $this->embed($client)->get('http://public.test/article'); + + $this->assertSame('Page title', $info->title); + $this->assertNull($info->publishedTime); + $this->assertSame([], self::$server->requests()); + $this->assertCount(1, $client->requests); + $this->assertSame('public.test', $client->requests[0]->getUri()->getHost()); + } + + /** + * @dataProvider schemaContextUrls + */ + public function testSchemaOrgContextResolvesWithoutNetwork(string $context): void + { + $client = $this->htmlClient($this->page($context)); + + $info = $this->embed($client)->get('http://public.test/article'); + + $published = $info->publishedTime; + $this->assertInstanceOf(DateTime::class, $published); + $this->assertSame('2017-09-14 10:20:35', $published->format('Y-m-d H:i:s')); + $this->assertSame('2017-09-14T10:20:35+00:00', $info->getLinkedData()->get('datePublished')); + $this->assertSame([], self::$server->requests()); + $this->assertCount(1, $client->requests); + } + + public function testSchemaOrgContextInAnArrayStillResolves(): void + { + $client = $this->htmlClient($this->page([ + 'https://schema.org/', + ['@language' => 'en'], + ])); + + $info = $this->embed($client)->get('http://public.test/article'); + + $published = $info->publishedTime; + $this->assertInstanceOf(DateTime::class, $published); + $this->assertSame('2017-09-14 10:20:35', $published->format('Y-m-d H:i:s')); + $this->assertSame([], self::$server->requests()); + } + + /** + * @dataProvider ignoredContexts + */ + public function testUnknownContextDoesNotBreakExtraction(string $context): void + { + $client = $this->htmlClient($this->page($context)); + + $info = $this->embed($client)->get('http://public.test/article'); + + $this->assertSame('Page title', $info->title); + $this->assertNull($info->publishedTime); + $this->assertNull($info->getLinkedData()->get('datePublished')); + $this->assertSame([], self::$server->requests()); + $this->assertCount(1, $client->requests); + } + + public function testMalformedJsonLdDoesNotBreakExtraction(): void + { + $invalidJson = 'Page title' + .'' + .''; + $invalidContext = $this->page(['@vocab' => []]); + + foreach ([$invalidJson, $invalidContext] as $html) { + self::$server->clearLog(); + $client = $this->htmlClient($html); + $info = $this->embed($client)->get('http://public.test/article'); + + $this->assertSame('Page title', $info->title); + $this->assertNull($info->publishedTime); + $this->assertSame([], self::$server->requests()); + } + } + + public function testLoaderDoesNotReadLocalFilesOrRemoteUrls(): void + { + $path = tempnam(sys_get_temp_dir(), 'embed-jsonld-'); + $this->assertIsString($path); + file_put_contents($path, 'SENTINEL-DO-NOT-LOAD'); + + $loader = new DocumentLoader(); + $targets = [ + $path, + 'file://'.$path, + 'http://127.0.0.1:'.self::$server->port().'/secret', + 'https://example.test/context.jsonld', + 'https://schema.org/Person', + ]; + + try { + foreach ($targets as $target) { + try { + $loader->loadDocument($target); + $this->fail('Expected '.$target.' to be refused.'); + } catch (JsonLdException $exception) { + $this->assertStringNotContainsString('SENTINEL-DO-NOT-LOAD', $exception->getMessage()); + } + } + + $remote = $loader->loadDocument('https://schema.org/docs/jsonldcontext.jsonld'); + $this->assertIsObject($remote->document); + $context = $remote->document->{'@context'}; + $this->assertIsObject($context); + $this->assertSame('http://schema.org/', $context->{'@vocab'}); + $this->assertSame([], self::$server->requests()); + } finally { + unlink($path); + } + } + + /** + * @return array + */ + public function schemaContextUrls(): array + { + $urls = [ + 'http://schema.org', + 'https://schema.org', + 'http://schema.org/', + 'https://schema.org/', + 'http://www.schema.org', + 'https://www.schema.org/', + 'http://schema.googleapis.com/', + 'https://schema.googleapis.com', + 'https://SCHEMA.ORG/docs/jsonldcontext.json', + 'https://schema.org/docs/jsonldcontext.jsonld', + 'http://www.schema.org/docs/jsonldcontext.jsonld/', + 'https://schema.googleapis.com/docs/jsonldcontext.jsonld', + 'https://schema.org/docs/jsonldcontext.json.jsonld', + 'https://schema.org/.jsonld', + 'https://schema.org/docs/jsonldcontext.jsonld?hl=en#vocab', + ]; + + $cases = []; + foreach ($urls as $url) { + $cases[$url] = [$url]; + } + + return $cases; + } + + /** + * @return array + */ + public function ignoredContexts(): array + { + return [ + 'other host' => ['https://example.test/context.jsonld'], + 'schema.org page' => ['https://schema.org/Person'], + 'lookalike host' => ['https://schema.org.evil.com/docs/jsonldcontext.jsonld'], + 'loopback' => ['http://127.0.0.1/context.jsonld'], + 'link local' => ['http://169.254.169.254/latest/meta-data/'], + 'file' => ['file:///etc/passwd'], + ]; + } + + /** + * @param string|array $context + */ + private function page($context): string + { + $json = json_encode([ + '@context' => $context, + '@type' => 'Article', + 'datePublished' => '2017-09-14T10:20:35+00:00', + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if (!is_string($json)) { + $json = '{}'; + } + + return 'Page title' + .'' + .''; + } + + private function embed(RecordingClient $client): Embed + { + $crawler = new Crawler($client); + $crawler->setUrlPolicy(UrlPolicy::default()->withResolver(static function (string $host): array { + return ['1.1.1.1']; + })); + + return new Embed($crawler); + } + + private function htmlClient(string $html): RecordingClient + { + return new RecordingClient(static function (RequestInterface $request) use ($html): ResponseInterface { + $factory = new Psr17Factory(); + + return $factory->createResponse(200) + ->withHeader('Content-Type', 'text/html') + ->withBody($factory->createStream($html)); + }); + } +} diff --git a/tests/SchemaOrgContext.php b/tests/SchemaOrgContext.php deleted file mode 100644 index 17a56ed3..00000000 --- a/tests/SchemaOrgContext.php +++ /dev/null @@ -1,137 +0,0 @@ -isSchemaHost($path)) { - return false; - } - - $this->body = self::BODY; - $this->position = 0; - $this->notifyMimeType(); - - return true; - } - - /** - * @param int $count - */ - public function stream_read($count): string - { - $chunk = substr($this->body, $this->position, $count); - $this->position += strlen($chunk); - - return $chunk; - } - - public function stream_eof(): bool - { - return $this->position >= strlen($this->body); - } - - /** - * @return int - */ - public function stream_tell() - { - return $this->position; - } - - /** - * @return array - */ - public function stream_stat(): array - { - $size = strlen($this->body); - - return [ - 'size' => $size, - 7 => $size, - ]; - } - - public function stream_close(): void - { - // The context body is a string, so there is no handle to close. - } - - private function notifyMimeType(): void - { - if (!isset($this->context)) { - return; - } - - $params = stream_context_get_params($this->context); - if (!isset($params['notification']) || !is_callable($params['notification'])) { - return; - } - - $params['notification']( - STREAM_NOTIFY_MIME_TYPE_IS, - STREAM_NOTIFY_SEVERITY_INFO, - 'application/ld+json', - 0, - 0, - 0 - ); - } - - /** - * @param mixed $url - */ - private function isSchemaHost($url): bool - { - if (!is_string($url) || $url === '') { - return false; - } - - $host = parse_url($url, PHP_URL_HOST); - if (!is_string($host) || $host === '') { - return false; - } - - return in_array(strtolower($host), [ - 'schema.org', - 'www.schema.org', - 'schema.googleapis.com', - ], true); - } -} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index b93a76cd..9c79257d 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,5 +2,3 @@ declare(strict_types = 1); require dirname(__DIR__).'/vendor/autoload.php'; - -Embed\Tests\SchemaOrgContext::register();