fix: whitelist GlitchTip ingest host in CSP connect-src
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>
This commit is contained in:
109
api/tests/Feature/Security/CspConnectsToObservabilityTest.php
Normal file
109
api/tests/Feature/Security/CspConnectsToObservabilityTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user