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:
Paul Nothaft
2026-09-02 09:43:10 +02:00
parent fe5ac9162d
commit 34685505be
7 changed files with 771 additions and 11 deletions
@@ -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>
)}