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;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,12 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Crewli — Organizer</title>
|
||||
<!-- CSP for local development — mirrors production Nginx policy -->
|
||||
<!-- CSP for local development — mirrors production Nginx policy.
|
||||
connect-src includes http://localhost:8200 so the @sentry/vue SDK
|
||||
can POST events to the local GlitchTip stack (RFC-WS-7 §3.5).
|
||||
Without this, the browser silently blocks every Sentry event. -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://localhost:8000 ws://localhost:5174; form-action 'self'; base-uri 'self'">
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' http://localhost:8000 ws://localhost:5174 http://localhost:8200; form-action 'self'; base-uri 'self'">
|
||||
<link rel="stylesheet" type="text/css" href="/loader.css" />
|
||||
</head>
|
||||
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
# Vite bundles all JS/CSS into same-origin files.
|
||||
# 'unsafe-inline' for style-src is required by Vuetify (inline styles for theming).
|
||||
# img-src https: allows organisation logos loaded from external URLs.
|
||||
# connect-src must include the API domain for XHR/fetch calls.
|
||||
# connect-src must include:
|
||||
# - https://api.crewli.app (XHR/fetch to the API)
|
||||
# - https://monitoring.hausdesign.nl (RFC-WS-7 §3.5: GlitchTip event ingest;
|
||||
# without it the browser silently blocks
|
||||
# every @sentry/vue POST)
|
||||
#
|
||||
# IMPORTANT: Start with Content-Security-Policy-Report-Only to catch
|
||||
# false positives. Switch to Content-Security-Policy after 1-2 weeks
|
||||
# of clean logs.
|
||||
|
||||
# Report-only mode (start with this):
|
||||
# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
|
||||
# add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app https://monitoring.hausdesign.nl; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
|
||||
|
||||
# Enforce mode (switch to this after testing):
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://api.crewli.app https://monitoring.hausdesign.nl; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" always;
|
||||
|
||||
@@ -610,6 +610,7 @@ Audit scope: all files under `api/` and `apps/` (app, portal).
|
||||
- **Description:** ~~Neither app set a CSP meta tag or header.~~
|
||||
- **Risk:** Injected scripts have unrestricted access.
|
||||
- **Resolution:** API CSP enforced via `SecurityHeaders` middleware (`default-src 'none'; frame-ancestors 'none'`). SPA CSP configured via Nginx snippets (`deploy/nginx/csp-spa.conf`, `csp-portal.conf`). Dev CSP meta tags added to all `index.html` files for local testing. See `deploy/README.md` for rollout instructions.
|
||||
- **WS-7 follow-up (mei 2026):** SPA `connect-src` whitelists the GlitchTip event-ingest endpoint as an explicit security control — dev `http://localhost:8200`, prod `https://monitoring.hausdesign.nl` (RFC-WS-7 §3.5). This restricts outgoing observability traffic to a single known host; without it, the strict CSP would either silently drop events (PR-3 regression) or — if loosened blindly — allow exfiltration to arbitrary hosts. Regression-guard: `tests/Feature/Security/CspConnectsToObservabilityTest.php` reads both the dev meta tag and the production nginx config and asserts the host is present.
|
||||
|
||||
#### [LOW] A13-10: No hardcoded secrets found in frontend code (positive)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user