diff --git a/src/GraphQl/Action/GraphiQlAction.php b/src/GraphQl/Action/GraphiQlAction.php index ca67f91e03..2858664777 100644 --- a/src/GraphQl/Action/GraphiQlAction.php +++ b/src/GraphQl/Action/GraphiQlAction.php @@ -40,9 +40,28 @@ public function __invoke(Request $request): Response 'title' => $this->title, 'graphiql_data' => ['entrypoint' => $this->router->generate('api_graphql_entrypoint')], 'assetPackage' => $this->assetPackage, + 'cspNonce' => $this->resolveCspNonce($request), ]), 200, ['content-type' => 'text/html']); } throw new BadRequestHttpException('GraphiQL is not enabled.'); } + + private function resolveCspNonce(Request $request): ?string + { + $nonce = $request->attributes->get('_csp_nonce'); + if (\is_string($nonce) && '' !== $nonce) { + return $nonce; + } + + // Reuse the nonce generated by NelmioSecurityBundle (or any bundle exposing a `csp_nonce` + // Twig function) so the emitted nonce matches the one added to the CSP response header. + if ($function = $this->twig->getFunction('csp_nonce')) { + $nonce = ($function->getCallable())('script'); + + return \is_string($nonce) && '' !== $nonce ? $nonce : null; + } + + return null; + } } diff --git a/src/GraphQl/Tests/Action/GraphiQlActionTest.php b/src/GraphQl/Tests/Action/GraphiQlActionTest.php index b2249a7cb9..2e43ad104b 100644 --- a/src/GraphQl/Tests/Action/GraphiQlActionTest.php +++ b/src/GraphQl/Tests/Action/GraphiQlActionTest.php @@ -52,6 +52,7 @@ private function getGraphiQlAction(bool $enabled): GraphiQlAction { $twigProphecy = $this->prophesize(TwigEnvironment::class); $twigProphecy->render(Argument::cetera())->willReturn(''); + $twigProphecy->getFunction('csp_nonce')->willReturn(null); $routerProphecy = $this->prophesize(RouterInterface::class); $routerProphecy->generate('api_graphql_entrypoint')->willReturn('/graphql'); diff --git a/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig b/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig index d901a7556b..60b6c06a4b 100644 --- a/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig @@ -9,8 +9,10 @@ {% if title %}{{ title }} - {% endif %}API Platform {% endblock %} + {%- set csp_nonce_attr = cspNonce is defined and cspNonce is not null ? ' nonce="' ~ (cspNonce|e('html_attr')) ~ '"' : '' -%} + {% block importmap %} - + {% endblock %} @@ -43,7 +45,7 @@
Loading...
{% block body_javascript %} - + {% endblock %} diff --git a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 52dfca324e..6c9cd4f650 100644 --- a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -13,6 +13,8 @@ {% set active_ui = app.request.query.get('ui', 'swagger_ui') %} {% set is_scalar = (scalarEnabled and not swaggerUiEnabled and not reDocEnabled) or (scalarEnabled and 'scalar' == active_ui) %} + {%- set csp_nonce_attr = cspNonce is defined and cspNonce is not null ? ' nonce="' ~ (cspNonce|e('html_attr')) ~ '"' : '' -%} + {% block stylesheet %} {% if not is_scalar %} @@ -26,7 +28,7 @@ {% block head_javascript %} {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} - + {% endblock %} @@ -101,18 +103,18 @@ {% block javascript %} {% if is_scalar %} - - + + {% elseif (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} - - + + {% else %} - - - + + + {% endif %} {% if not is_scalar %} - + {% endif %} {% endblock %} diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php index 065ada1ea1..af55f3485d 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php @@ -19,6 +19,7 @@ use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait; use ApiPlatform\State\ProcessorInterface; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -58,6 +59,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'assetPackage' => $this->swaggerUiContext->getAssetPackage(), 'originalRoute' => $request->attributes->get('_api_original_route', $request->attributes->get('_route')), 'originalRouteParams' => $request->attributes->get('_api_original_route_params', $request->attributes->get('_route_params', [])), + 'cspNonce' => $this->resolveCspNonce($request), ]; $swaggerData = [ @@ -97,6 +99,24 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable return new Response($this->twig->render('@ApiPlatform/SwaggerUi/index.html.twig', $swaggerContext + ['swagger_data' => $swaggerData]), $status); } + private function resolveCspNonce(?Request $request): ?string + { + $nonce = $request?->attributes->get('_csp_nonce'); + if (\is_string($nonce) && '' !== $nonce) { + return $nonce; + } + + // Reuse the nonce generated by NelmioSecurityBundle (or any bundle exposing a `csp_nonce` + // Twig function) so the emitted nonce matches the one added to the CSP response header. + if ($function = $this->twig->getFunction('csp_nonce')) { + $nonce = ($function->getCallable())('script'); + + return \is_string($nonce) && '' !== $nonce ? $nonce : null; + } + + return null; + } + /** * @param array $swaggerData */ diff --git a/tests/Functional/GraphQl/GraphiqlCspNonceTest.php b/tests/Functional/GraphQl/GraphiqlCspNonceTest.php new file mode 100644 index 0000000000..d773636a7c --- /dev/null +++ b/tests/Functional/GraphQl/GraphiqlCspNonceTest.php @@ -0,0 +1,168 @@ + + * + * 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\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +final class GraphiqlCspNonceRequestListener +{ + public function __construct(private readonly string $nonce) + { + } + + public function __invoke(RequestEvent $event): void + { + $event->getRequest()->attributes->set('_csp_nonce', $this->nonce); + } +} + +final class GraphiqlCspNonceTwigExtension extends AbstractExtension +{ + public function getFunctions(): array + { + return [ + new TwigFunction('csp_nonce', static fn (string $directive = 'script'): string => 'function-nonce-'.$directive), + ]; + } +} + +class GraphiqlCspNonceAppKernel extends \AppKernel +{ + public static bool $requestNonceEnabled = false; + public static bool $cspNonceFunctionEnabled = false; + + private function suffix(): string + { + return (self::$requestNonceEnabled ? 'req_' : 'no_req_').(self::$cspNonceFunctionEnabled ? 'fn' : 'no_fn'); + } + + public function getCacheDir(): string + { + return parent::getCacheDir().'/graphiql_csp_'.$this->suffix(); + } + + public function getLogDir(): string + { + return parent::getLogDir().'/graphiql_csp_'.$this->suffix(); + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'graphql' => ['enabled' => true, 'graphiql' => ['enabled' => true]], + ]); + + if (GraphiqlCspNonceAppKernel::$requestNonceEnabled) { + $container->register('test.csp_nonce_listener', GraphiqlCspNonceRequestListener::class) + ->setArguments(['request-nonce-123']) + ->setPublic(true) + ->addTag('kernel.event_listener', ['event' => KernelEvents::REQUEST, 'priority' => 256]); + } + + if (GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled) { + $container->register('test.csp_nonce_twig_extension', GraphiqlCspNonceTwigExtension::class) + ->setPublic(true) + ->addTag('twig.extension'); + } + }); + } +} + +final class GraphiqlCspNonceTest extends ApiTestCase +{ + protected static ?bool $alwaysBootKernel = true; + + protected static function getKernelClass(): string + { + return GraphiqlCspNonceAppKernel::class; + } + + protected function tearDown(): void + { + GraphiqlCspNonceAppKernel::$requestNonceEnabled = false; + GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled = false; + + parent::tearDown(); + } + + public function testRequestAttributeNonceIsEmittedOnScripts(): void + { + GraphiqlCspNonceAppKernel::$requestNonceEnabled = true; + GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled = false; + + $client = self::createClient(); + $client->request('GET', '/graphql/graphiql', ['headers' => ['Accept' => 'text/html']]); + + $this->assertResponseIsSuccessful(); + $content = $client->getResponse()->getContent(); + + $this->assertStringContainsString('