Files
crewli/api/tests/Feature/Security/CspConnectsToObservabilityTest.php
bert.hausmans 5c42f27b26 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>
2026-05-07 18:36:05 +02:00

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;
}
}