From 16f4f9aca29e06c894164f2fad7b1b8c77300d05 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 3 Jul 2026 07:25:41 +0200 Subject: [PATCH] fix(symfony): don't expose entrypoint in openapi format The entrypoint's Get operation used %api_platform.docs_formats% for its outputFormats, which includes openapi/yamlopenapi. No EntrypointNormalizer exists for those formats (only jsonld, jsonhal and jsonapi have one, plus a dedicated Swagger UI code path for html), so content negotiation let the format through and the Symfony ObjectNormalizer fallback serialized the public ResourceNameCollection property, leaking internal PHP FQCNs. Introduce an api_platform.entrypoint_formats parameter, the intersection of docs_formats with the formats the entrypoint actually knows how to render (jsonld, jsonhal, jsonapi, html), and use it instead of docs_formats for the entrypoint action in both the controller and event-listener service definitions. Documentation formats now correctly yield a 404 (unsupported route format suffix, e.g. /index.jsonopenapi) or 406 (unsupported Accept header) instead of a 200 with leaked class names. enable_entrypoint default is intentionally left unchanged. Also guard ContentNegotiationTrait::getRequestFormat() against a null mime type: an unknown route _format suffix (e.g. jsonopenapi) makes Request::getMimeType() return null, which triggered a PHP 8.5 "null as array offset" deprecation and would have thrown a TypeError on the $throw path. Fixes #8361 --- src/Metadata/Util/ContentNegotiationTrait.php | 4 +- .../ApiPlatformExtension.php | 5 + .../Resources/config/symfony/controller.php | 2 +- .../Resources/config/symfony/events.php | 2 +- tests/Functional/EntrypointFormatTest.php | 125 ++++++++++++++++++ tests/Functional/OpenApiTest.php | 22 +-- 6 files changed, 148 insertions(+), 12 deletions(-) create mode 100644 tests/Functional/EntrypointFormatTest.php diff --git a/src/Metadata/Util/ContentNegotiationTrait.php b/src/Metadata/Util/ContentNegotiationTrait.php index 9f951261a4e..4f8cb15f650 100644 --- a/src/Metadata/Util/ContentNegotiationTrait.php +++ b/src/Metadata/Util/ContentNegotiationTrait.php @@ -119,12 +119,12 @@ private function getRequestFormat(Request $request, array $formats, bool $throw if (null !== $requestFormat) { $mimeType = $request->getMimeType($requestFormat); - if (isset($flattenedMimeTypes[$mimeType])) { + if (null !== $mimeType && isset($flattenedMimeTypes[$mimeType])) { return $requestFormat; } if ($throw) { - throw $this->getNotAcceptableHttpException($mimeType, $flattenedMimeTypes); + throw $this->getNotAcceptableHttpException($mimeType ?? $requestFormat, $flattenedMimeTypes); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 14410c5ff01..9b9019b368c 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -365,6 +365,11 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.patch_formats', $patchFormats); $container->setParameter('api_platform.error_formats', $errorFormats); $container->setParameter('api_platform.docs_formats', $docsFormats); + // The entrypoint only has normalizers for hypermedia formats (jsonld, jsonhal, jsonapi) and a + // dedicated Swagger UI code path for html. Other documentation formats (e.g. openapi, yamlopenapi) + // have no Entrypoint normalizer and must not be advertised, otherwise content negotiation lets them + // through and the Symfony ObjectNormalizer fallback leaks the internal ResourceNameCollection FQCNs. + $container->setParameter('api_platform.entrypoint_formats', array_intersect_key($docsFormats, array_flip(['jsonld', 'jsonhal', 'jsonapi', 'html']))); $container->setParameter('api_platform.jsonschema_formats', []); $container->setParameter('api_platform.eager_loading.enabled', $this->isConfigEnabled($container, $config['eager_loading'])); $container->setParameter('api_platform.eager_loading.max_joins', $config['eager_loading']['max_joins']); diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 2ac24e613e0..3bbed6d6106 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -36,7 +36,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.main'), service('api_platform.state_processor.main'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e70..91b784f07eb 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -184,7 +184,7 @@ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.state_provider.documentation'), service('api_platform.state_processor.documentation'), - '%api_platform.docs_formats%', + '%api_platform.entrypoint_formats%', ]); $services->set('api_platform.action.documentation', DocumentationAction::class) diff --git a/tests/Functional/EntrypointFormatTest.php b/tests/Functional/EntrypointFormatTest.php new file mode 100644 index 00000000000..b1ac4df6780 --- /dev/null +++ b/tests/Functional/EntrypointFormatTest.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\MultipleResourceBook; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * The entrypoint must only expose hypermedia formats that have a dedicated + * EntrypointNormalizer (jsonld, jsonhal, jsonapi). Documentation formats + * (openapi, html) have no such normalizer and must not be served, otherwise + * the Symfony ObjectNormalizer fallback dumps the public ResourceNameCollection, + * leaking internal PHP FQCNs. + * + * @see https://github.com/api-platform/core/issues/8361 + */ +final class EntrypointFormatTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MultipleResourceBook::class]; + } + + public function testEntrypointRejectsOpenApiAcceptHeader(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(406); + } + + /** + * The ".jsonopenapi"/".yamlopenapi" URL suffixes are resolved by routing before + * content negotiation runs, so an unsupported route format yields a 404 + * (consistent with any other resource requested with an unsupported format + * suffix), not a 406. + */ + public function testEntrypointRejectsOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.jsonopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointRejectsYamlOpenApiFormatSuffix(): void + { + $response = self::createClient()->request('GET', '/index.yamlopenapi', [ + 'headers' => ['accept' => 'application/vnd.openapi+yaml'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testEntrypointStillServesHtmlAsSwaggerUi(): void + { + $response = self::createClient()->request('GET', '/index.html', [ + 'headers' => ['accept' => 'text/html'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('swagger-ui', $response->getContent()); + } + + public function testEntrypointStillServesJsonLd(): void + { + $response = self::createClient()->request('GET', '/index.jsonld', [ + 'headers' => ['accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('multipleResourceBook2', $data); + $this->assertEquals('/multi_route_books', $data['multipleResourceBook2']); + $this->assertStringNotContainsString('resourceNameCollection', $response->getContent()); + } + + public function testEntrypointStillServesJsonHal(): void + { + $response = self::createClient()->request('GET', '/index.jsonhal', [ + 'headers' => ['accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/hal+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('_links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['_links']); + } + + public function testEntrypointStillServesJsonApi(): void + { + $response = self::createClient()->request('GET', '/index.jsonapi', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertArrayHasKey('links', $data); + $this->assertArrayHasKey('multipleResourceBook2', $data['links']); + } +} diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 8cb8120b63d..1a59307a44f 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -686,24 +686,30 @@ public function testOpenApiUiIsEnabledForDocsEndpointWithDummyObject(): void $this->assertResponseIsSuccessful(); } - public function testRetrieveTheEntrypoint(): void + /** + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormat(): void { $response = self::createClient()->request('GET', '/', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(406); } - public function testRetrieveTheEntrypointWithUrlFormat(): void + /** + * The ".jsonopenapi" URL suffix is resolved by routing before content negotiation + * runs, so an unsupported route format yields a 404 (consistent with any other + * resource requested with an unsupported format suffix), not a 406. + * + * @see https://github.com/api-platform/core/issues/8361 + */ + public function testEntrypointRejectsOpenApiFormatWithUrlFormat(): void { $response = self::createClient()->request('GET', '/index.jsonopenapi', [ 'headers' => ['Accept' => 'application/vnd.openapi+json'], ]); - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.openapi+json; charset=utf-8'); - $this->assertJson($response->getContent()); + $this->assertResponseStatusCodeSame(404); } public function testOpenApiSchemaWithNormalizationAttributes(): void