fix: production CSP blocking PostHog/Sentry/Scalar and silent failure hardening

The production CSP had connect-src/script-src/font-src set to 'self' only,
silently blocking all analytics and error reporting in production while
working fine in dev (where CSP is not applied).

CSP fixes:
- Add PostHog ingest + assets origins to connect-src and script-src
- Add Sentry ingest origin to connect-src
- Add Scalar fonts origin to font-src for API docs pages
- Extract CSP construction into testable buildCsp() function

Silent failure hardening:
- Settings/features stores now set loadError flag and allow retry on
  subsequent fetch() calls instead of permanently caching failed state
- Analytics init no longer sets initialized=true before the try block,
  allowing retry on failure
- Settings dialog Tools section disables save button when settings
  failed to load, preventing accidental config wipe
- Branding logo storage moved from process.cwd() to FILES_STORAGE_PATH
  so logos persist across Docker container recreation

Test coverage:
- 16 CSP directive tests covering all external service domains
- Store retry-on-error behavior tests for settings and features stores
- Analytics init retry-after-failure test
This commit is contained in:
SnapOtter
2026-05-05 17:16:19 +08:00
parent fe86c5ac9c
commit e358634f8b
10 changed files with 183 additions and 25 deletions
@@ -2292,6 +2292,7 @@ function AuditLogSection() {
function ToolsSection() {
const [disabledTools, setDisabledTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [saving, setSaving] = useState(false);
const [search, setSearch] = useState("");
const [showRestartBanner, setShowRestartBanner] = useState(false);
@@ -2302,8 +2303,9 @@ function ToolsSection() {
setDisabledTools(
data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [],
);
setLoadFailed(false);
})
.catch(() => {})
.catch(() => setLoadFailed(true))
.finally(() => setLoading(false));
}, []);
@@ -2424,11 +2426,17 @@ function ToolsSection() {
</p>
)}
{loadFailed && (
<div className="px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm text-red-700 dark:text-red-400">
Failed to load tool settings. Saving is disabled to prevent data loss.
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
disabled={saving || loadFailed}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}