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
+21
View File
@@ -89,6 +89,27 @@ server {
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# Analytics tracker proxy (backend). The tracker script is served from our
# own origin so `script-src 'self'` / `connect-src 'self'` above already
# cover it and no admin-configured tracker domain has to be added to the
# CSP by hand. It NEEDS its own block: the script URL ends in `.js`, and
# the `~* \.(js|css|…)$` regex location above outranks the plain `/api`
# prefix in nginx's matching order, so `/api/analytics/tracker/script.js`
# would otherwise be looked up as a static file and 404. `^~` stops regex
# evaluation for this prefix. Keep in sync with the mount path in
# backend/server.js.
location ^~ /api/analytics/tracker/ {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
proxy_read_timeout 30s;
client_max_body_size 64k;
}
# API proxy
location /api {
# Use variable to force DNS resolution per request (required for Docker Swarm)
@@ -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>
)}
@@ -0,0 +1,85 @@
/**
* Pins the same-origin tracker URLs.
*
* Umami and Rybbit used to be injected with a `src` pointing at the admin's
* own tracker domain, which the shipped CSP (`script-src 'self' …`,
* `connect-src 'self' …`) always blocked — silently, with only a console
* error. The script and its beacon now go through PicPeak's own origin
* (`/api/analytics/tracker/*`, proxied by the backend), which `'self'`
* already covers. If any of these URLs regress to the tracker's domain the
* feature breaks again, invisibly, so the shapes are pinned here.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { analyticsService } from '../analytics.service';
function lastScript(): HTMLScriptElement {
const scripts = document.head.querySelectorAll('script');
return scripts[scripts.length - 1] as HTMLScriptElement;
}
function freshService() {
// The service is a module-level singleton with an `initialized` latch; each
// case needs its own instance.
return new (analyticsService.constructor as new () => typeof analyticsService)();
}
describe('analytics tracker script injection', () => {
beforeEach(() => {
document.head.innerHTML = '';
});
afterEach(() => {
document.head.innerHTML = '';
});
it('loads the Umami script from PicPeak\'s own origin, not the tracker domain', () => {
freshService().initialize({
provider: 'umami',
hostUrl: 'https://analytics.example.com',
websiteId: 'site-123',
doNotTrack: true,
});
const script = lastScript();
expect(script.getAttribute('src')).toBe('/api/analytics/tracker/script.js');
expect(script.getAttribute('src')).not.toContain('analytics.example.com');
// Umami derives its collect endpoint as `<data-host-url>/api/send`, so
// this is what keeps the beacon inside `connect-src 'self'` too.
expect(script.getAttribute('data-host-url')).toBe('/api/analytics/tracker');
expect(script.getAttribute('data-website-id')).toBe('site-123');
// GHSA-7m6c: auto-track stays off so the raw gallery URL (which carries
// the share token) never reaches the collector.
expect(script.getAttribute('data-auto-track')).toBe('false');
});
it('loads the Rybbit script from a prefix its own host-derivation can parse', () => {
freshService().initialize({
provider: 'rybbit',
hostUrl: 'https://rybbit.example.com',
websiteId: 'site-456',
doNotTrack: true,
maskPatterns: ['/gallery/**'],
});
const script = lastScript();
const src = script.getAttribute('src')!;
expect(src).toBe('/api/analytics/tracker/script.js');
// Rybbit computes `analyticsHost = src.split('/script.js')[0]` and then
// calls `<host>/track`; the split has to land on our proxy prefix.
expect(src.split('/script.js')[0]).toBe('/api/analytics/tracker');
expect(script.getAttribute('data-site-id')).toBe('site-456');
expect(script.getAttribute('data-mask-patterns')).toBe(JSON.stringify(['/gallery/**']));
});
it('injects nothing when the tracker URL is missing', () => {
freshService().initialize({
provider: 'umami',
hostUrl: '',
websiteId: 'site-123',
});
expect(document.head.querySelectorAll('script')).toHaveLength(0);
});
});
+28 -2
View File
@@ -10,8 +10,25 @@
// Custom → render admin-pasted HTML (sanitised server-side) into <head>;
// no runtime API hook — `track()` becomes a no-op.
import { getApiBaseUrl } from '../utils/url';
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
// Umami and Rybbit scripts are loaded from PicPeak's OWN origin and proxied to
// the configured tracker by `backend/src/routes/analyticsTrackerProxy.js`.
// Loading them from the tracker's domain directly was always blocked by the
// shipped CSP (`script-src 'self' …`, and `connect-src 'self' …` for the
// beacon), which cannot be made dynamic in the Docker deployment — nginx
// serves index.html off disk and strips the backend's CSP header. Proxying
// removes the need for a CSP change entirely, and is what both vendors
// document for first-party tracking.
//
// Both scripts derive their collect endpoint from their own `src`:
// Umami — `<script-dir>/api/send` (also honours data-host-url, set below)
// Rybbit — `src.split('/script.js')[0]` + `/track`
// so the single prefix below is all the backend has to expose.
const trackerProxyBase = (): string => `${getApiBaseUrl().replace(/\/+$/, '')}/analytics/tracker`;
interface BaseInitConfig {
provider: TrackerProvider;
autoTrack?: boolean;
@@ -83,10 +100,16 @@ class AnalyticsService {
return;
}
this.websiteId = config.websiteId;
const proxyBase = trackerProxyBase();
const script = document.createElement('script');
script.async = true;
script.defer = true;
script.src = `${config.hostUrl.replace(/\/+$/, '')}/script.js`;
script.src = `${proxyBase}/script.js`;
// Pin the collect host explicitly rather than relying on the tracker's
// src-directory fallback: an Umami built with COLLECT_API_HOST set would
// otherwise post straight to the tracker's domain and be blocked by
// `connect-src 'self'`.
script.setAttribute('data-host-url', proxyBase);
script.setAttribute('data-website-id', config.websiteId);
// Auto-track OFF by default (GHSA-7m6c): Umami's auto page-view capture
// reads window.location verbatim, so a gallery URL /gallery/:slug/:token
@@ -106,7 +129,10 @@ class AnalyticsService {
const script = document.createElement('script');
script.async = true;
script.defer = true;
script.src = `${config.hostUrl.replace(/\/+$/, '')}/api/script.js`;
// `/script.js` (not the upstream's `/api/script.js`): Rybbit's script
// computes its analytics host as `src.split('/script.js')[0]`, so the
// proxy prefix has to be the part before that literal segment.
script.src = `${trackerProxyBase()}/script.js`;
script.setAttribute('data-site-id', config.websiteId);
// GHSA-7m6c: Rybbit auto-tracks page views (initial load + SPA route
// changes) reading window.location, so a gallery URL would ship the raw