fix(analytics): serve self-hosted trackers same-origin so CSP stops blocking
A self-hosted Umami/Rybbit domain configured in Settings could never load: the CSP script-src allowlist is static, and the earlier pass could only add an admin-visible warning because nginx.conf:58 strips helmet's header and location / serves the SPA document off disk via try_files -- so helmet can never govern it in Docker. Verified by reading the config, not inferred; that kills the "make helmet dynamic" option outright. Rather than templating the CSP, the tracker is now same-origin. The script and every endpoint it talks to are served from /api/analytics/tracker/* and proxied server-side to the configured instance, so script-src 'self' and connect-src 'self' already cover it. The CSP is unchanged: nothing to template, no env var, no restart -- it takes effect when Settings is saved. That also closes A3 structurally rather than by widening a directive. Endpoint mapping taken from vendor sources, not guessed: Umami's host || currentScript.src + /api/send, and Rybbit's documented /track, /site/tracking-config/<id>, /site/<id>/feature-flags/evaluate. data-host-url is set explicitly so a COLLECT_API_HOST-built Umami cannot bypass the proxy. Session replay is deliberately NOT proxied: replaying gallery pages would capture the share token (GHSA-7m6c). nginx still needed one line, for a non-obvious reason: the static-asset regex location outranks the plain /api prefix in nginx's matching order, so /api/analytics/tracker/script.js resolved as a static file. Confirmed empirically against a real nginx:alpine -- 404 before the ^~ block, 502 (proxied) after, with /assets/app.js and /api/public/settings unchanged. The native SERVE_FRONTEND install needed no change; helmet already has 'self' in both directives and the proxy mounts ahead of express.static. Security boundary, since this makes the server fetch an admin-supplied URL: closed per-provider path+method allowlist (4 paths), DNS-resolving isHostAllowed blocking private/internal/metadata addresses in production (matching the s3Storage prod-only precedent), base rebuilt as origin + pathname so userinfo/query/fragment cannot smuggle anything, redirect: 'error', cookie/authorization/referer/host never forwarded, an HTML upstream response re-served as application/octet-stream + nosniff, and 64KB request / 2MB response / 5s timeout / 120rpm caps. X-Forwarded-For and User-Agent are forwarded so geo and device attribution survive. Residual, stated plainly: an unauthenticated rate-limited relay to one admin-chosen public host on 4 paths, and TOCTOU DNS rebinding is unmitigated as it is elsewhere in the repo. The Umami and Rybbit panels now explain they are proxied; the Custom panel keeps a CSP warning -- it is the one mode with nothing to proxy -- naming both script-src and connect-src. Refs testplan REPORT.md A2, A3.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Save, Globe, Key, Activity, AlertCircle, Code } from 'lucide-react';
|
||||
import { Save, Globe, Key, Activity, AlertCircle, Code, ShieldCheck } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { AnalyticsSettings, TrackerProvider } from '../hooks/useSettingsState';
|
||||
@@ -16,10 +16,38 @@ interface AnalyticsTabProps {
|
||||
const PROVIDER_OPTIONS: TrackerProvider[] = ['none', 'umami', 'rybbit', 'custom'];
|
||||
|
||||
/**
|
||||
* The shipped CSP `script-src` is a static allowlist that no configured
|
||||
* tracker domain is ever added to, so a self-hosted Umami/Rybbit instance is
|
||||
* blocked by the browser with nothing but a console error to show for it.
|
||||
* Shown for every provider that loads a script from another origin.
|
||||
* Umami and Rybbit are loaded through PicPeak's own origin (the backend
|
||||
* proxies `/api/analytics/tracker/*` to the URL configured above), so the
|
||||
* shipped `script-src 'self'` / `connect-src 'self'` CSP already covers both
|
||||
* the script and its beacon and nothing has to be allowlisted by hand.
|
||||
*/
|
||||
const ProxiedNotice: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">
|
||||
{t('settings.analytics.proxiedNotice', 'Served from your own domain')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'settings.analytics.proxiedNoticeText',
|
||||
'PicPeak loads the tracker script and forwards its events through its own domain, so no Content-Security-Policy or reverse-proxy change is needed — and ad blockers see first-party requests. Your tracker still gets each visitor\'s IP and user agent (forwarded as X-Forwarded-For), so device and location reporting keeps working. The URL must be reachable from the PicPeak server and resolve to a public address.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom mode is the one provider that still loads from a third-party origin:
|
||||
* the pasted snippet is rendered verbatim into <head>, so PicPeak has nothing
|
||||
* to proxy and the static CSP applies to it unchanged.
|
||||
*/
|
||||
const CspWarning: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -34,8 +62,12 @@ const CspWarning: React.FC = () => {
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'settings.analytics.customCspWarningText',
|
||||
'PicPeak ships with a strict CSP (`script-src \'self\'`). If your tracker loads from another domain, add that domain to your reverse-proxy or nginx CSP config — otherwise the browser silently blocks the script.',
|
||||
// Deliberately a NEW key: the old `customCspWarningText` value is
|
||||
// still in en/de and describes the pre-proxy world ("your tracker
|
||||
// loads from another domain", script-src only), which is now only
|
||||
// true for Custom mode and is missing connect-src.
|
||||
'settings.analytics.customOnlyCspWarningText',
|
||||
'PicPeak ships with a strict CSP (`script-src \'self\'; connect-src \'self\'`). Unlike the Umami and Rybbit options above, a pasted snippet is not proxied — add your tracker\'s domain to BOTH `script-src` (to load the script) and `connect-src` (for the events it sends) in your reverse-proxy or nginx CSP config, otherwise the browser silently blocks it.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -158,7 +190,7 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CspWarning />
|
||||
<ProxiedNotice />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -223,7 +255,7 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CspWarning />
|
||||
<ProxiedNotice />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user