Skip to content

Commit a9f111c

Browse files
committed
feat(symfony): emit a csp nonce on the swagger ui and graphiql scripts
Under a CSP like "script-src 'self' 'strict-dynamic'" the bundled SwaggerUI and GraphiQL scripts were blocked because they carried no nonce. Emit one, reusing existing mechanisms with no new config key: a `_csp_nonce` request attribute takes precedence, otherwise the nelmio `csp_nonce('script')` Twig function is reused (so the emitted nonce matches the one nelmio adds to the CSP response header); absent both, output is unchanged. The nonce is resolved in PHP, so no `csp_nonce()` call appears in the templates and plain installs without nelmio are unaffected. Fixes #8142
1 parent 573df71 commit a9f111c

7 files changed

Lines changed: 395 additions & 12 deletions

File tree

src/GraphQl/Action/GraphiQlAction.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,28 @@ public function __invoke(Request $request): Response
4040
'title' => $this->title,
4141
'graphiql_data' => ['entrypoint' => $this->router->generate('api_graphql_entrypoint')],
4242
'assetPackage' => $this->assetPackage,
43+
'cspNonce' => $this->resolveCspNonce($request),
4344
]), 200, ['content-type' => 'text/html']);
4445
}
4546

4647
throw new BadRequestHttpException('GraphiQL is not enabled.');
4748
}
49+
50+
private function resolveCspNonce(Request $request): ?string
51+
{
52+
$nonce = $request->attributes->get('_csp_nonce');
53+
if (\is_string($nonce) && '' !== $nonce) {
54+
return $nonce;
55+
}
56+
57+
// Reuse the nonce generated by NelmioSecurityBundle (or any bundle exposing a `csp_nonce`
58+
// Twig function) so the emitted nonce matches the one added to the CSP response header.
59+
if ($function = $this->twig->getFunction('csp_nonce')) {
60+
$nonce = ($function->getCallable())('script');
61+
62+
return \is_string($nonce) && '' !== $nonce ? $nonce : null;
63+
}
64+
65+
return null;
66+
}
4867
}

src/GraphQl/Tests/Action/GraphiQlActionTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ private function getGraphiQlAction(bool $enabled): GraphiQlAction
5252
{
5353
$twigProphecy = $this->prophesize(TwigEnvironment::class);
5454
$twigProphecy->render(Argument::cetera())->willReturn('');
55+
$twigProphecy->getFunction('csp_nonce')->willReturn(null);
5556
$routerProphecy = $this->prophesize(RouterInterface::class);
5657
$routerProphecy->generate('api_graphql_entrypoint')->willReturn('/graphql');
5758

src/Symfony/Bundle/Resources/views/Graphiql/index.html.twig

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
<title>{% if title %}{{ title }} - {% endif %}API Platform</title>
1010
{% endblock %}
1111

12+
{%- set csp_nonce_attr = cspNonce is defined and cspNonce is not null ? ' nonce="' ~ (cspNonce|e('html_attr')) ~ '"' : '' -%}
13+
1214
{% block importmap %}
13-
<script type="importmap">
15+
<script type="importmap"{{ csp_nonce_attr|raw }}>
1416
{
1517
"imports": {
1618
"react": "https://esm.sh/react@19.2.5",
@@ -35,15 +37,15 @@
3537

3638
{% block head_javascript %}
3739
{# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #}
38-
<script id="graphiql-data" type="application/json">{{ graphiql_data|json_encode(65)|raw }}</script>
40+
<script id="graphiql-data" type="application/json"{{ csp_nonce_attr|raw }}>{{ graphiql_data|json_encode(65)|raw }}</script>
3941
{% endblock %}
4042
</head>
4143

4244
<body>
4345
<div id="graphiql">Loading...</div>
4446

4547
{% block body_javascript %}
46-
<script type="module" src="{{ asset('bundles/apiplatform/init-graphiql.js', assetPackage) }}"></script>
48+
<script type="module" src="{{ asset('bundles/apiplatform/init-graphiql.js', assetPackage) }}"{{ csp_nonce_attr|raw }}></script>
4749
{% endblock %}
4850

4951
</body>

src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
{% set active_ui = app.request.query.get('ui', 'swagger_ui') %}
1414
{% set is_scalar = (scalarEnabled and not swaggerUiEnabled and not reDocEnabled) or (scalarEnabled and 'scalar' == active_ui) %}
1515

16+
{%- set csp_nonce_attr = cspNonce is defined and cspNonce is not null ? ' nonce="' ~ (cspNonce|e('html_attr')) ~ '"' : '' -%}
17+
1618
{% block stylesheet %}
1719
{% if not is_scalar %}
1820
<link rel="stylesheet" href="{{ asset('bundles/apiplatform/fonts/open-sans/400.css', assetPackage) }}">
@@ -26,7 +28,7 @@
2628

2729
{% block head_javascript %}
2830
{# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #}
29-
<script id="swagger-data" type="application/json">{{ swagger_data|merge(oauth_data)|json_encode(65)|raw }}</script>
31+
<script id="swagger-data" type="application/json"{{ csp_nonce_attr|raw }}>{{ swagger_data|merge(oauth_data)|json_encode(65)|raw }}</script>
3032
{% endblock %}
3133
</head>
3234

@@ -101,18 +103,18 @@
101103

102104
{% block javascript %}
103105
{% if is_scalar %}
104-
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
105-
<script src="{{ asset('bundles/apiplatform/init-scalar-ui.js', assetPackage) }}"></script>
106+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"{{ csp_nonce_attr|raw }}></script>
107+
<script src="{{ asset('bundles/apiplatform/init-scalar-ui.js', assetPackage) }}"{{ csp_nonce_attr|raw }}></script>
106108
{% elseif (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %}
107-
<script src="{{ asset('bundles/apiplatform/redoc/redoc.standalone.js', assetPackage) }}"></script>
108-
<script src="{{ asset('bundles/apiplatform/init-redoc-ui.js', assetPackage) }}"></script>
109+
<script src="{{ asset('bundles/apiplatform/redoc/redoc.standalone.js', assetPackage) }}"{{ csp_nonce_attr|raw }}></script>
110+
<script src="{{ asset('bundles/apiplatform/init-redoc-ui.js', assetPackage) }}"{{ csp_nonce_attr|raw }}></script>
109111
{% else %}
110-
<script src="{{ asset('bundles/apiplatform/swagger-ui/swagger-ui-bundle.js', assetPackage) }}"></script>
111-
<script src="{{ asset('bundles/apiplatform/swagger-ui/swagger-ui-standalone-preset.js', assetPackage) }}"></script>
112-
<script src="{{ asset('bundles/apiplatform/init-swagger-ui.js', assetPackage) }}"></script>
112+
<script src="{{ asset('bundles/apiplatform/swagger-ui/swagger-ui-bundle.js', assetPackage) }}"{{ csp_nonce_attr|raw }}></script>
113+
<script src="{{ asset('bundles/apiplatform/swagger-ui/swagger-ui-standalone-preset.js', assetPackage) }}"{{ csp_nonce_attr|raw }}></script>
114+
<script src="{{ asset('bundles/apiplatform/init-swagger-ui.js', assetPackage) }}"{{ csp_nonce_attr|raw }}></script>
113115
{% endif %}
114116
{% if not is_scalar %}
115-
<script src="{{ asset('bundles/apiplatform/init-common-ui.js', assetPackage) }}" defer></script>
117+
<script src="{{ asset('bundles/apiplatform/init-common-ui.js', assetPackage) }}" defer{{ csp_nonce_attr|raw }}></script>
116118
{% endif %}
117119
{% endblock %}
118120

src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use ApiPlatform\OpenApi\Options;
2020
use ApiPlatform\OpenApi\Serializer\NormalizeOperationNameTrait;
2121
use ApiPlatform\State\ProcessorInterface;
22+
use Symfony\Component\HttpFoundation\Request;
2223
use Symfony\Component\HttpFoundation\Response;
2324
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
2425
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -58,6 +59,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable
5859
'assetPackage' => $this->swaggerUiContext->getAssetPackage(),
5960
'originalRoute' => $request->attributes->get('_api_original_route', $request->attributes->get('_route')),
6061
'originalRouteParams' => $request->attributes->get('_api_original_route_params', $request->attributes->get('_route_params', [])),
62+
'cspNonce' => $this->resolveCspNonce($request),
6163
];
6264

6365
$swaggerData = [
@@ -97,6 +99,24 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable
9799
return new Response($this->twig->render('@ApiPlatform/SwaggerUi/index.html.twig', $swaggerContext + ['swagger_data' => $swaggerData]), $status);
98100
}
99101

102+
private function resolveCspNonce(?Request $request): ?string
103+
{
104+
$nonce = $request?->attributes->get('_csp_nonce');
105+
if (\is_string($nonce) && '' !== $nonce) {
106+
return $nonce;
107+
}
108+
109+
// Reuse the nonce generated by NelmioSecurityBundle (or any bundle exposing a `csp_nonce`
110+
// Twig function) so the emitted nonce matches the one added to the CSP response header.
111+
if ($function = $this->twig->getFunction('csp_nonce')) {
112+
$nonce = ($function->getCallable())('script');
113+
114+
return \is_string($nonce) && '' !== $nonce ? $nonce : null;
115+
}
116+
117+
return null;
118+
}
119+
100120
/**
101121
* @param array<string, mixed> $swaggerData
102122
*/
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Functional\GraphQl;
15+
16+
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
17+
use Symfony\Component\Config\Loader\LoaderInterface;
18+
use Symfony\Component\DependencyInjection\ContainerBuilder;
19+
use Symfony\Component\HttpKernel\Event\RequestEvent;
20+
use Symfony\Component\HttpKernel\KernelEvents;
21+
use Twig\Extension\AbstractExtension;
22+
use Twig\TwigFunction;
23+
24+
final class GraphiqlCspNonceRequestListener
25+
{
26+
public function __construct(private readonly string $nonce)
27+
{
28+
}
29+
30+
public function __invoke(RequestEvent $event): void
31+
{
32+
$event->getRequest()->attributes->set('_csp_nonce', $this->nonce);
33+
}
34+
}
35+
36+
final class GraphiqlCspNonceTwigExtension extends AbstractExtension
37+
{
38+
public function getFunctions(): array
39+
{
40+
return [
41+
new TwigFunction('csp_nonce', static fn (string $directive = 'script'): string => 'function-nonce-'.$directive),
42+
];
43+
}
44+
}
45+
46+
class GraphiqlCspNonceAppKernel extends \AppKernel
47+
{
48+
public static bool $requestNonceEnabled = false;
49+
public static bool $cspNonceFunctionEnabled = false;
50+
51+
private function suffix(): string
52+
{
53+
return (self::$requestNonceEnabled ? 'req_' : 'no_req_').(self::$cspNonceFunctionEnabled ? 'fn' : 'no_fn');
54+
}
55+
56+
public function getCacheDir(): string
57+
{
58+
return parent::getCacheDir().'/graphiql_csp_'.$this->suffix();
59+
}
60+
61+
public function getLogDir(): string
62+
{
63+
return parent::getLogDir().'/graphiql_csp_'.$this->suffix();
64+
}
65+
66+
protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void
67+
{
68+
parent::configureContainer($c, $loader);
69+
70+
$loader->load(static function (ContainerBuilder $container): void {
71+
$container->loadFromExtension('api_platform', [
72+
'graphql' => ['enabled' => true, 'graphiql' => ['enabled' => true]],
73+
]);
74+
75+
if (GraphiqlCspNonceAppKernel::$requestNonceEnabled) {
76+
$container->register('test.csp_nonce_listener', GraphiqlCspNonceRequestListener::class)
77+
->setArguments(['request-nonce-123'])
78+
->setPublic(true)
79+
->addTag('kernel.event_listener', ['event' => KernelEvents::REQUEST, 'priority' => 256]);
80+
}
81+
82+
if (GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled) {
83+
$container->register('test.csp_nonce_twig_extension', GraphiqlCspNonceTwigExtension::class)
84+
->setPublic(true)
85+
->addTag('twig.extension');
86+
}
87+
});
88+
}
89+
}
90+
91+
final class GraphiqlCspNonceTest extends ApiTestCase
92+
{
93+
protected static ?bool $alwaysBootKernel = true;
94+
95+
protected static function getKernelClass(): string
96+
{
97+
return GraphiqlCspNonceAppKernel::class;
98+
}
99+
100+
protected function tearDown(): void
101+
{
102+
GraphiqlCspNonceAppKernel::$requestNonceEnabled = false;
103+
GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled = false;
104+
105+
parent::tearDown();
106+
}
107+
108+
public function testRequestAttributeNonceIsEmittedOnScripts(): void
109+
{
110+
GraphiqlCspNonceAppKernel::$requestNonceEnabled = true;
111+
GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled = false;
112+
113+
$client = self::createClient();
114+
$client->request('GET', '/graphql/graphiql', ['headers' => ['Accept' => 'text/html']]);
115+
116+
$this->assertResponseIsSuccessful();
117+
$content = $client->getResponse()->getContent();
118+
119+
$this->assertStringContainsString('<script type="importmap" nonce="request-nonce-123">', $content);
120+
$this->assertStringContainsString('<script id="graphiql-data" type="application/json" nonce="request-nonce-123">', $content);
121+
$this->assertStringContainsString('init-graphiql.js" nonce="request-nonce-123"', $content);
122+
}
123+
124+
public function testCspNonceFunctionIsEmittedOnScripts(): void
125+
{
126+
GraphiqlCspNonceAppKernel::$requestNonceEnabled = false;
127+
GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled = true;
128+
129+
$client = self::createClient();
130+
$client->request('GET', '/graphql/graphiql', ['headers' => ['Accept' => 'text/html']]);
131+
132+
$this->assertResponseIsSuccessful();
133+
$content = $client->getResponse()->getContent();
134+
135+
$this->assertStringContainsString('<script type="importmap" nonce="function-nonce-script">', $content);
136+
$this->assertStringContainsString('init-graphiql.js" nonce="function-nonce-script"', $content);
137+
}
138+
139+
public function testRequestAttributeNonceTakesPrecedenceOverFunction(): void
140+
{
141+
GraphiqlCspNonceAppKernel::$requestNonceEnabled = true;
142+
GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled = true;
143+
144+
$client = self::createClient();
145+
$client->request('GET', '/graphql/graphiql', ['headers' => ['Accept' => 'text/html']]);
146+
147+
$this->assertResponseIsSuccessful();
148+
$content = $client->getResponse()->getContent();
149+
150+
$this->assertStringContainsString('nonce="request-nonce-123"', $content);
151+
$this->assertStringNotContainsString('nonce="function-nonce-script"', $content);
152+
}
153+
154+
public function testNoNonceIsEmittedWhenNoMechanismAvailable(): void
155+
{
156+
GraphiqlCspNonceAppKernel::$requestNonceEnabled = false;
157+
GraphiqlCspNonceAppKernel::$cspNonceFunctionEnabled = false;
158+
159+
$client = self::createClient();
160+
$client->request('GET', '/graphql/graphiql', ['headers' => ['Accept' => 'text/html']]);
161+
162+
$this->assertResponseIsSuccessful();
163+
$content = $client->getResponse()->getContent();
164+
165+
$this->assertStringContainsString('init-graphiql.js', $content);
166+
$this->assertStringNotContainsString('nonce=', $content);
167+
}
168+
}

0 commit comments

Comments
 (0)