From 1b9455bdaa8611b9548b5527a68f59fce026d7da Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 4 Aug 2026 12:49:21 +0200 Subject: [PATCH] Add horizon nonce --- app/Http/Middleware/ContentSecurityPolicy.php | 19 ++---- tests/Unit/ContentSecurityPolicyTest.php | 62 +++++++++++++++++++ 2 files changed, 67 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/ContentSecurityPolicyTest.php diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php index f3827b9f9..fc6ddfdb6 100644 --- a/app/Http/Middleware/ContentSecurityPolicy.php +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -7,6 +7,7 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Vite; +use Laravel\Horizon\Horizon; use Symfony\Component\HttpFoundation\Response; class ContentSecurityPolicy @@ -19,20 +20,10 @@ class ContentSecurityPolicy // Tell Vite to add the CSP nonce to generated script/style tags $nonce = function_exists('csp_nonce') ? csp_nonce() : base64_encode(random_bytes(16)); Vite::useCspNonce($nonce); + Horizon::cspNonce($nonce); $response = $next($request); - // Skip strict CSP for Horizon dashboard (admin-only). Horizon uses inline - // scripts and does not support nonces; it is already protected by auth/role. - $horizonPath = trim(config('horizon.path', 'horizon'), '/'); - if ($horizonPath !== '' && $request->is($horizonPath, $horizonPath.'/*')) { - $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); - $response->headers->set('X-Content-Type-Options', 'nosniff'); - $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); - - return $response; - } - // Check if Turnstile is enabled $turnstileEnabled = config('captcha.provider') === 'turnstile' && config('captcha.turnstile.enabled') === true; @@ -63,9 +54,9 @@ class ContentSecurityPolicy "script-src 'self' 'nonce-{$nonce}' 'unsafe-eval' 'strict-dynamic' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com blob:", "script-src-elem 'self' 'nonce-{$nonce}' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com", "script-src-attr 'none'", - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud", - "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud", - "font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud data:", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.bunny.net https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud", + "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.bunny.net https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud", + "font-src 'self' https://fonts.gstatic.com https://fonts.bunny.net https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud data:", "img-src 'self' data: https: blob:", "connect-src 'self' https://www.google.com https://cdn.tiny.cloud https://sp.tinymce.com", "frame-src 'self' https://www.google.com https://www.gstatic.com https://challenges.cloudflare.com https://cdn.tiny.cloud data: blob:", diff --git a/tests/Unit/ContentSecurityPolicyTest.php b/tests/Unit/ContentSecurityPolicyTest.php new file mode 100644 index 000000000..88452a620 --- /dev/null +++ b/tests/Unit/ContentSecurityPolicyTest.php @@ -0,0 +1,62 @@ +originalContainer = Container::getInstance(); + + $container = new Container; + $container->instance('config', new Repository([ + 'captcha' => [ + 'provider' => null, + 'turnstile' => ['enabled' => false], + ], + ])); + $container->instance(Vite::class, new Vite); + + Container::setInstance($container); + Facade::setFacadeApplication($container); + } + + protected function tearDown(): void + { + Horizon::$nonceAttribute = ''; + Container::setInstance($this->originalContainer); + Facade::setFacadeApplication($this->originalContainer); + + parent::tearDown(); + } + + public function test_horizon_inline_assets_receive_the_csp_nonce(): void + { + $response = (new ContentSecurityPolicy)->handle( + Request::create('/horizon'), + static fn (): Response => new Response(Horizon::css().Horizon::js()), + ); + + $nonce = app('csp_nonce'); + + $this->assertStringContainsString("script-src 'self' 'nonce-{$nonce}'", (string) $response->headers->get('Content-Security-Policy')); + $this->assertStringContainsString('https://fonts.bunny.net', (string) $response->headers->get('Content-Security-Policy')); + $this->assertSame(4, substr_count((string) $response->getContent(), 'nonce="'.$nonce.'"')); + } +}