diff --git a/composer.json b/composer.json index f719196a..551869ef 100644 --- a/composer.json +++ b/composer.json @@ -44,6 +44,7 @@ "filp/whoops": "^2.15", "kahlan/kahlan": "^5.2", "mikey179/vfsstream": "^1.6", + "paragonie/csp-builder": "^3.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^1.11", "phpstan/phpstan-strict-rules": "^1.6", diff --git a/spec/system/framework/Middlewares/Csp.spec.php b/spec/system/framework/Middlewares/Csp.spec.php new file mode 100644 index 00000000..d41914a4 --- /dev/null +++ b/spec/system/framework/Middlewares/Csp.spec.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use BlitzPHP\Http\Response; +use BlitzPHP\Http\ServerRequestFactory; +use BlitzPHP\Middlewares\Csp; +use ParagonIE\CSPBuilder\CSPBuilder; +use Spec\BlitzPHP\Middlewares\TestRequestHandler; + +use function Kahlan\expect; + +describe('Middleware / Csp', function (): void { + beforeAll(function () { + $this->getRequestHandler = function () { + return new TestRequestHandler(function ($request) { + return new Response(); + }); + }; + }); + + it('Process ajoute les headers', function (): void { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test']); + + $middleware = new Csp([ + 'script-src' => [ + 'allow' => [ + 'https://www.google-analytics.com', + ], + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + ]); + + $response = $middleware->process($request, $this->getRequestHandler()); + $policy = $response->getHeaderLine('Content-Security-Policy'); + + $expected = "script-src 'self' https://www.google-analytics.com"; + + expect(str_contains($policy, $expected))->toBeTruthy(); + expect(str_contains($policy, 'nonce-'))->toBeFalsy(); + }); + + it('Process ajoute les attributs de requete pour nonces', function (): void { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test']); + + $policy = [ + 'script-src' => [ + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + 'style-src' => [ + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + ]; + + $middleware = new Csp($policy, [ + 'script_nonce' => true, + 'style_nonce' => true, + ]); + + $handler = new TestRequestHandler(function ($request) { + expect($request->getAttribute('cspScriptNonce'))->not->toBeEmpty(); + expect($request->getAttribute('cspStyleNonce'))->not->toBeEmpty(); + + return new Response(); + }); + + $response = $middleware->process($request, $handler); + $policy = $response->getHeaderLine('Content-Security-Policy'); + $expected = [ + "script-src 'self' 'nonce-", + "style-src 'self' 'nonce-", + ]; + + expect($policy)->not->toBeEmpty(); + + foreach ($expected as $match) { + expect(str_contains($policy, $match))->toBeTruthy(); + } + }); + + it('Passage d\'une instance CSPBuilder', function () { + $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/test']); + + $config = [ + 'script-src' => [ + 'allow' => [ + 'https://www.google-analytics.com', + ], + 'self' => true, + 'unsafe-inline' => false, + 'unsafe-eval' => false, + ], + ]; + + $cspBuilder = new CSPBuilder($config); + $middleware = new Csp($cspBuilder); + + $response = $middleware->process($request, $this->getRequestHandler()); + $policy = $response->getHeaderLine('Content-Security-Policy'); + $expected = "script-src 'self' https://www.google-analytics.com"; + + expect(str_contains($policy, $expected))->toBeTruthy(); + }); +}); diff --git a/src/Middlewares/Csp.php b/src/Middlewares/Csp.php new file mode 100644 index 00000000..c6bb8ee1 --- /dev/null +++ b/src/Middlewares/Csp.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Middlewares; + +use BlitzPHP\Exceptions\FrameworkException; +use BlitzPHP\Traits\InstanceConfigTrait; +use ParagonIE\CSPBuilder\CSPBuilder; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\MiddlewareInterface; +use Psr\Http\Server\RequestHandlerInterface; + +/** + * Content Security Policy Middleware + * + * ### Options + * + * - `script_nonce` Permet d'ajouter une politique de nonce à la directive script-src. + * - `style_nonce` Permet d'ajouter une politique de nonce à la directive style-src. + */ +class Csp implements MiddlewareInterface +{ + use InstanceConfigTrait; + + /** + * CSP Builder + */ + protected CSPBuilder $csp; + + /** + * Options de configuration. + * + * @var array + */ + protected array $_defaultConfig = [ + 'script_nonce' => false, + 'style_nonce' => false, + ]; + + /** + * Constructor + * + * @param array|CSPBuilder $csp Objet CSP ou tableau de configuration + * @param array $config options de configurations. + */ + public function __construct(array|CSPBuilder $csp, array $config = []) + { + if (! class_exists(CSPBuilder::class)) { + throw new FrameworkException('Vous devez installer paragonie/csp-builder pour utiliser le middleware Csp.'); + } + + $this->setConfig($config); + + if (! $csp instanceof CSPBuilder) { + $csp = new CSPBuilder($csp); + } + + $this->csp = $csp; + } + + /** + * Ajoute les nonces (s'ils sont activés) à la requete et applique l'en-tête CSP à la réponse. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->getConfig('script_nonce')) { + $request = $request->withAttribute('cspScriptNonce', $this->csp->nonce('script-src')); + } + if ($this->getConfig('style_nonce')) { + $request = $request->withAttribute('cspStyleNonce', $this->csp->nonce('style-src')); + } + + $response = $handler->handle($request); + + /** @var ResponseInterface */ + return $this->csp->injectCSPHeader($response); + } +}