Files
fotospiel-app/app/Http/Middleware/ContentSecurityPolicy.php
Codex Agent 71604c6e41
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
Fix CSP nonce timing for admin styles
2026-01-24 20:54:23 +01:00

167 lines
4.9 KiB
PHP

<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
class ContentSecurityPolicy
{
public function handle(Request $request, Closure $next): Response
{
$scriptNonce = base64_encode(random_bytes(16));
$styleNonce = base64_encode(random_bytes(16));
$request->attributes->set('csp_script_nonce', $scriptNonce);
$request->attributes->set('csp_style_nonce', $styleNonce);
View::share('cspNonce', $scriptNonce);
View::share('cspStyleNonce', $styleNonce);
Vite::useCspNonce($scriptNonce);
$response = $next($request);
if (app()->environment('local') || config('app.debug')) {
return $response;
}
if ($response->headers->has('Content-Security-Policy')) {
return $response;
}
$matomoOrigin = $this->normaliseOrigin(config('services.matomo.url'));
$scriptSources = [
"'self'",
"'nonce-{$scriptNonce}'",
'https://cdn.paddle.com',
'https://global.localizecdn.com',
];
$styleSources = [
"'self'",
"'nonce-{$styleNonce}'",
'https:',
];
$connectSources = [
"'self'",
'https://api.paddle.com',
'https://sandbox-api.paddle.com',
'https://checkout.paddle.com',
'https://sandbox-checkout.paddle.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
'https://global.localizecdn.com',
];
$frameSources = [
"'self'",
'https://checkout.paddle.com',
'https://sandbox-checkout.paddle.com',
'https://checkout-service.paddle.com',
'https://sandbox-checkout-service.paddle.com',
];
$imgSources = [
"'self'",
'data:',
'blob:',
'https:',
];
$fontSources = [
"'self'",
'data:',
'https:',
];
$mediaSources = [
"'self'",
'data:',
'blob:',
'https:',
];
if ($matomoOrigin) {
$scriptSources[] = $matomoOrigin;
$connectSources[] = $matomoOrigin;
$imgSources[] = $matomoOrigin;
}
$isDev = app()->environment(['local', 'development']) || config('app.debug');
if ($isDev) {
$devHosts = [
'http://fotospiel-app.test:5173',
'http://127.0.0.1:5173',
'https://localhost:5173',
'https://127.0.0.1:5173',
];
$wsHosts = [
'ws://fotospiel-app.test:5173',
'ws://127.0.0.1:5173',
'wss://localhost:5173',
'wss://127.0.0.1:5173',
];
$scriptSources = array_merge($scriptSources, $devHosts, ["'unsafe-inline'", "'unsafe-eval'"]);
$styleSources = array_merge($styleSources, $devHosts, ["'unsafe-inline'"]);
$connectSources = array_merge($connectSources, $devHosts, $wsHosts);
$fontSources = array_merge($fontSources, $devHosts);
$mediaSources = array_merge($mediaSources, $devHosts);
}
$styleSources[] = 'data:';
$connectSources[] = 'https:';
$fontSources[] = 'https:';
$directives = [
'default-src' => ["'self'"],
'script-src' => array_unique($scriptSources),
'style-src' => array_unique($styleSources),
'style-src-attr' => ["'unsafe-inline'"],
'img-src' => array_unique($imgSources),
'font-src' => array_unique($fontSources),
'connect-src' => array_unique($connectSources),
'media-src' => array_unique($mediaSources),
'frame-src' => array_unique($frameSources),
'form-action' => ["'self'"],
'base-uri' => ["'self'"],
'object-src' => ["'none'"],
'frame-ancestors' => ["'self'"],
];
$csp = collect($directives)
->map(fn ($values, $directive) => $directive.' '.implode(' ', array_filter($values)))
->implode('; ');
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
private function normaliseOrigin(?string $url): ?string
{
if (! $url) {
return null;
}
$parsed = parse_url($url);
if (! $parsed || ! isset($parsed['scheme'], $parsed['host'])) {
return null;
}
$origin = strtolower($parsed['scheme'].'://'.$parsed['host']);
if (isset($parsed['port'])) {
$origin .= ':'.$parsed['port'];
}
return $origin;
}
}