security: implement CSP headers (API middleware + Nginx configs + dev meta tags)
API middleware: - SecurityHeaders now sets Content-Security-Policy from config/security.php - Default API policy: "default-src 'none'; frame-ancestors 'none'" - Supports report-only mode via CSP_REPORT_ONLY env var - Policy value configurable via CSP_POLICY env var Nginx deployment configs (deploy/nginx/): - security-headers.conf: shared headers for all server blocks - csp-api.conf: restrictive JSON-only policy for api.crewli.app - csp-spa.conf: SPA policy for app/admin (self + unsafe-inline styles) - csp-portal.conf: portal policy matching SPA Development: - CSP meta tags added to all three index.html files - Includes 'unsafe-inline' + 'unsafe-eval' for Vite HMR/loader script - Each app allows its own ws:// port for HMR websocket Resolves security finding A13-9. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,15 @@ final class SecurityHeaders
|
||||
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
$csp = config('security.csp');
|
||||
if ($csp) {
|
||||
$headerName = config('security.csp_report_only')
|
||||
? 'Content-Security-Policy-Report-Only'
|
||||
: 'Content-Security-Policy';
|
||||
|
||||
$response->headers->set($headerName, $csp);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
36
api/config/security.php
Normal file
36
api/config/security.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Content Security Policy
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| CSP header value. Set to null to disable. Use report-only mode first
|
||||
| to identify violations before enforcing.
|
||||
|
|
||||
| For the API: restrictive policy (no scripts, no styles needed).
|
||||
| For SPAs: CSP is configured at the web server (Nginx) level because
|
||||
| Vite-generated script hashes change per build.
|
||||
|
|
||||
*/
|
||||
|
||||
'csp' => env('CSP_POLICY', "default-src 'none'; frame-ancestors 'none'"),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CSP Report Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When true, sends Content-Security-Policy-Report-Only instead of
|
||||
| Content-Security-Policy. Violations are logged but not blocked.
|
||||
| Use this for initial rollout to catch false positives.
|
||||
|
|
||||
*/
|
||||
|
||||
'csp_report_only' => env('CSP_REPORT_ONLY', false),
|
||||
|
||||
];
|
||||
65
api/tests/Feature/Security/CspHeaderTest.php
Normal file
65
api/tests/Feature/Security/CspHeaderTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Security;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
final class CspHeaderTest extends TestCase
|
||||
{
|
||||
public function test_api_responses_include_csp_header(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/');
|
||||
|
||||
$response->assertHeader('Content-Security-Policy');
|
||||
}
|
||||
|
||||
public function test_api_csp_is_restrictive(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/');
|
||||
|
||||
$csp = $response->headers->get('Content-Security-Policy');
|
||||
$this->assertStringContainsString("default-src 'none'", $csp);
|
||||
$this->assertStringContainsString("frame-ancestors 'none'", $csp);
|
||||
}
|
||||
|
||||
public function test_csp_header_matches_config(): void
|
||||
{
|
||||
$expectedCsp = config('security.csp');
|
||||
|
||||
$response = $this->getJson('/api/v1/');
|
||||
|
||||
$response->assertHeader('Content-Security-Policy', $expectedCsp);
|
||||
}
|
||||
|
||||
public function test_report_only_mode_uses_report_only_header(): void
|
||||
{
|
||||
config(['security.csp_report_only' => true]);
|
||||
|
||||
$response = $this->getJson('/api/v1/');
|
||||
|
||||
$response->assertHeader('Content-Security-Policy-Report-Only');
|
||||
$this->assertNull($response->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
public function test_no_csp_header_when_policy_is_null(): void
|
||||
{
|
||||
config(['security.csp' => null]);
|
||||
|
||||
$response = $this->getJson('/api/v1/');
|
||||
|
||||
$this->assertNull($response->headers->get('Content-Security-Policy'));
|
||||
$this->assertNull($response->headers->get('Content-Security-Policy-Report-Only'));
|
||||
}
|
||||
|
||||
public function test_no_csp_header_when_policy_is_empty(): void
|
||||
{
|
||||
config(['security.csp' => '']);
|
||||
|
||||
$response = $this->getJson('/api/v1/');
|
||||
|
||||
$this->assertNull($response->headers->get('Content-Security-Policy'));
|
||||
$this->assertNull($response->headers->get('Content-Security-Policy-Report-Only'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user