PR-3 follow-up. Live smoke surfaced that the @sentry/vue SDK was running correctly and emitting events, but Crewli's strict connect-src directive blocked every POST at the browser layer. No fallback — events evaporated silently with a CSP-violation log in DevTools console only. Updated locations (audited the CSP surface; only two locations actually need the whitelist): - apps/app/index.html — dev meta CSP, adds http://localhost:8200 to connect-src so local dev hits the docker-compose GlitchTip stack. - deploy/nginx/csp-spa.conf — prod organizer SPA CSP, adds https://monitoring.hausdesign.nl to BOTH the report-only and enforce add_header lines so a future flip between modes can't silently break observability. NOT updated (deviation from prompt): - api/config/security.php — the API CSP is `default-src 'none'; frame-ancestors 'none'` for JSON responses. Browsers don't enforce connect-src on JSON contexts (no document, no fetch origin). Adding connect-src would be semantically a no-op and confuse the deny-by- default policy. Regression guard: tests/Feature/Security/CspConnectsToObservabilityTest. Reads both the dev meta tag and the prod nginx conf directly (the SPA's CSP is not Laravel-served, so $this->get() can't reach it). Apply-with- revert verified: stashing both fixes makes both cases fail with a clear "Refused to connect because it violates the following CSP directive" hint; popping the stash restores green. SECURITY_AUDIT.md A13-9 updated with a WS-7 follow-up note documenting the GlitchTip whitelist as an explicit security control: outgoing observability traffic restricted to a single known host. Test count 1549 to 1551. Larastan + Pint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
4.4 KiB
PHP
110 lines
4.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Security;
|
|
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* Regression guard: CSP connect-src must whitelist the GlitchTip ingest
|
|
* host on every SPA-serving environment (RFC-WS-7 §3.5). Without it, the
|
|
* browser silently blocks @sentry/vue's POST to the ingest endpoint and
|
|
* frontend observability evaporates with no fallback.
|
|
*
|
|
* Crewli's SPA CSP is NOT served by Laravel — it lives in:
|
|
* - apps/app/index.html (dev: meta http-equiv="Content-Security-Policy")
|
|
* - deploy/nginx/csp-spa.conf (prod: add_header Content-Security-Policy)
|
|
*
|
|
* The Laravel CSP (config/security.php) covers API JSON responses with
|
|
* `default-src 'none'`; browsers don't enforce connect-src on JSON
|
|
* contexts, so it intentionally has no connect-src directive.
|
|
*
|
|
* This test reads both source files directly — that's the only reliable
|
|
* way to assert the invariant on a CSP that the test framework cannot
|
|
* reach via $this->get(). Caught a real PR-3 regression: frontend SDK
|
|
* emitted events correctly, but Crewli's strict CSP blocked egress at
|
|
* the browser. Without this guard, the bug would resurface every time
|
|
* a CSP refactor happens.
|
|
*/
|
|
final class CspConnectsToObservabilityTest extends TestCase
|
|
{
|
|
private const DEV_GLITCHTIP_HOST = 'http://localhost:8200';
|
|
|
|
private const PROD_GLITCHTIP_HOST = 'https://monitoring.hausdesign.nl';
|
|
|
|
public function test_dev_meta_csp_whitelists_localhost_glitchtip(): void
|
|
{
|
|
$html = file_get_contents(base_path('../apps/app/index.html'));
|
|
$this->assertNotFalse($html, 'apps/app/index.html must be readable');
|
|
|
|
$csp = $this->extractMetaCsp($html);
|
|
$this->assertNotNull($csp, 'apps/app/index.html must declare a meta CSP');
|
|
|
|
$connectSrc = $this->extractDirective($csp, 'connect-src');
|
|
$this->assertNotNull($connectSrc, 'meta CSP must define connect-src');
|
|
|
|
$this->assertStringContainsString(
|
|
self::DEV_GLITCHTIP_HOST,
|
|
$connectSrc,
|
|
sprintf(
|
|
'Dev meta CSP connect-src must whitelist %s for the local GlitchTip stack (RFC-WS-7 §3.5). '
|
|
.'Without it the browser blocks @sentry/vue events with: "Refused to connect because it '
|
|
.'violates the following Content Security Policy directive: connect-src ...". '
|
|
.'Found connect-src: %s',
|
|
self::DEV_GLITCHTIP_HOST,
|
|
$connectSrc,
|
|
),
|
|
);
|
|
}
|
|
|
|
public function test_prod_nginx_csp_whitelists_monitoring_host(): void
|
|
{
|
|
$conf = file_get_contents(base_path('../deploy/nginx/csp-spa.conf'));
|
|
$this->assertNotFalse($conf, 'deploy/nginx/csp-spa.conf must be readable');
|
|
|
|
$matches = [];
|
|
$found = preg_match_all('/^\s*add_header\s+Content-Security-Policy(?:-Report-Only)?\s+"([^"]+)"\s+always;/m', $conf, $matches);
|
|
$this->assertNotEmpty($found, 'csp-spa.conf must contain at least one add_header Content-Security-Policy directive');
|
|
|
|
// Every uncommented add_header line must include the GlitchTip host
|
|
// in connect-src. Multiple lines exist (Report-Only + enforce); both
|
|
// need the whitelist or a refactor that flips between them silently
|
|
// breaks observability.
|
|
foreach ($matches[1] as $cspValue) {
|
|
$connectSrc = $this->extractDirective($cspValue, 'connect-src');
|
|
$this->assertNotNull($connectSrc, "csp-spa.conf CSP must define connect-src. Got: {$cspValue}");
|
|
$this->assertStringContainsString(
|
|
self::PROD_GLITCHTIP_HOST,
|
|
$connectSrc,
|
|
sprintf(
|
|
'Prod nginx CSP connect-src must whitelist %s (RFC-WS-7 §3.5). Found: %s',
|
|
self::PROD_GLITCHTIP_HOST,
|
|
$connectSrc,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
private function extractMetaCsp(string $html): ?string
|
|
{
|
|
if (preg_match('/<meta\s+http-equiv="Content-Security-Policy"\s+content="([^"]+)"/i', $html, $matches) === 1) {
|
|
return $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function extractDirective(string $cspValue, string $directive): ?string
|
|
{
|
|
foreach (explode(';', $cspValue) as $part) {
|
|
$part = trim($part);
|
|
if (str_starts_with($part, $directive.' ') || $part === $directive) {
|
|
return substr($part, strlen($directive));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|