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:
@@ -258,6 +258,14 @@ app.use('/api', cors(corsOptions));
|
|||||||
// Handle preflight explicitly for API paths
|
// Handle preflight explicitly for API paths
|
||||||
app.options('/api/*', cors(corsOptions));
|
app.options('/api/*', cors(corsOptions));
|
||||||
|
|
||||||
|
// Same-origin proxy for the configured analytics tracker. Mounted HERE, ahead
|
||||||
|
// of the body parsers, so the tracker's beacon payload reaches the proxy as a
|
||||||
|
// raw buffer (express.json would consume it, and the CSRF Content-Type gate
|
||||||
|
// below would 415 a navigator.sendBeacon `text/plain` POST). It carries no
|
||||||
|
// PicPeak state and reads no PicPeak credentials — see the route file for the
|
||||||
|
// SSRF/path-allowlist model.
|
||||||
|
app.use('/api/analytics/tracker', require('./src/routes/analyticsTrackerProxy'));
|
||||||
|
|
||||||
// Initialize rate limiters (they will be created dynamically)
|
// Initialize rate limiters (they will be created dynamically)
|
||||||
let generalRateLimiter;
|
let generalRateLimiter;
|
||||||
let authRateLimiter;
|
let authRateLimiter;
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Contract tests for the same-origin analytics-tracker proxy.
|
||||||
|
*
|
||||||
|
* The proxy exists so an admin-configured self-hosted Umami/Rybbit instance
|
||||||
|
* actually loads under the shipped `script-src 'self'` / `connect-src 'self'`
|
||||||
|
* CSP without anyone hand-editing nginx.conf. Because the upstream base URL
|
||||||
|
* comes from an admin-editable setting, most of what is pinned here is the
|
||||||
|
* SSRF/abuse boundary rather than the happy path: which paths are reachable,
|
||||||
|
* which headers cross the boundary, and what a hostile upstream can make the
|
||||||
|
* browser see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
const settings = {};
|
||||||
|
|
||||||
|
jest.mock('../utils/appSettings', () => ({
|
||||||
|
getAppSetting: jest.fn(async (key, defaultValue = null) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : defaultValue
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIsHostAllowed = jest.fn(async () => true);
|
||||||
|
jest.mock('../utils/networkValidation', () => ({ isHostAllowed: (...a) => mockIsHostAllowed(...a) }));
|
||||||
|
|
||||||
|
jest.mock('../utils/logger', () => ({
|
||||||
|
warn: jest.fn(), debug: jest.fn(), info: jest.fn(), error: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
// Fresh require per test: the route memoises the resolved upstream for 30s.
|
||||||
|
jest.resetModules();
|
||||||
|
const app = express();
|
||||||
|
app.use('/api/analytics/tracker', require('../routes/analyticsTrackerProxy'));
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function upstreamReply(body, { status = 200, contentType = 'text/javascript', headers = {} } = {}) {
|
||||||
|
return new Response(body, { status, headers: { 'content-type': contentType, ...headers } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of Object.keys(settings)) delete settings[key];
|
||||||
|
mockIsHostAllowed.mockClear();
|
||||||
|
mockIsHostAllowed.mockResolvedValue(true);
|
||||||
|
fetchMock = jest.fn(async () => upstreamReply('/* tracker */'));
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('analytics tracker proxy — reachability', () => {
|
||||||
|
it('404s when no tracker provider is configured', async () => {
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(404);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s when the provider is set but has no URL', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(404);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves Umami from the configured instance', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
|
||||||
|
const res = await request(buildApp()).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('https://umami.example.com/script.js');
|
||||||
|
expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8');
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
expect(res.text).toBe('/* tracker */');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a legacy umami_enabled install with no explicit provider', async () => {
|
||||||
|
settings.analytics_umami_enabled = true;
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('https://umami.example.com/script.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Rybbit paths onto the upstream /api prefix', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'rybbit';
|
||||||
|
settings.analytics_rybbit_url = 'https://rybbit.example.com/';
|
||||||
|
const app = buildApp();
|
||||||
|
|
||||||
|
await request(app).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('https://rybbit.example.com/api/script.js');
|
||||||
|
|
||||||
|
// mockImplementation, not mockResolvedValue: a Response body is a stream
|
||||||
|
// and can only be consumed once, so each call needs a fresh one.
|
||||||
|
fetchMock.mockImplementation(async () => upstreamReply('{}', { contentType: 'application/json' }));
|
||||||
|
await request(app)
|
||||||
|
.post('/api/analytics/tracker/track')
|
||||||
|
.set('content-type', 'application/json')
|
||||||
|
.send({ type: 'pageview' })
|
||||||
|
.expect(200);
|
||||||
|
expect(fetchMock.mock.calls[1][0]).toBe('https://rybbit.example.com/api/track');
|
||||||
|
|
||||||
|
await request(app).get('/api/analytics/tracker/site/tracking-config/abc-123').expect(200);
|
||||||
|
expect(fetchMock.mock.calls[2][0])
|
||||||
|
.toBe('https://rybbit.example.com/api/site/tracking-config/abc-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a sub-path in the configured URL', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://example.com/umami/';
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('https://example.com/umami/script.js');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('analytics tracker proxy — path allowlist', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/api/analytics/tracker/track'],
|
||||||
|
['/api/analytics/tracker/api/auth/login'],
|
||||||
|
['/api/analytics/tracker/site/tracking-config/abc'],
|
||||||
|
['/api/analytics/tracker/'],
|
||||||
|
['/api/analytics/tracker/script.js/../../secret'],
|
||||||
|
['/api/analytics/tracker/%2e%2e/%2e%2e/secret'],
|
||||||
|
['/api/analytics/tracker/index.html'],
|
||||||
|
])('404s on a path outside the provider allowlist: %s', async (path) => {
|
||||||
|
await request(buildApp()).get(path).expect(404);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s when the method does not match the allowlisted path', async () => {
|
||||||
|
// /api/send is POST-only; a GET must not be relayed.
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/api/send').expect(404);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relays the Umami beacon POST', async () => {
|
||||||
|
fetchMock.mockResolvedValue(upstreamReply('cache-token', { contentType: 'text/plain' }));
|
||||||
|
|
||||||
|
const res = await request(buildApp())
|
||||||
|
.post('/api/analytics/tracker/api/send')
|
||||||
|
.set('content-type', 'application/json')
|
||||||
|
.send({ type: 'event' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('https://umami.example.com/api/send');
|
||||||
|
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
|
||||||
|
expect(fetchMock.mock.calls[0][1].body.toString()).toBe(JSON.stringify({ type: 'event' }));
|
||||||
|
expect(res.headers['cache-control']).toBe('no-store');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('analytics tracker proxy — SSRF boundary', () => {
|
||||||
|
it('refuses a non-HTTP tracker URL', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'file:///etc/passwd';
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(404);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an unparseable tracker URL', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'not a url';
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(404);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a private/internal host in production', async () => {
|
||||||
|
const previous = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
mockIsHostAllowed.mockResolvedValue(false);
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'http://169.254.169.254';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(404);
|
||||||
|
expect(mockIsHostAllowed).toHaveBeenCalledWith('169.254.169.254');
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a localhost tracker outside production (dev parity with s3Storage)', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'http://localhost:3000';
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
expect(mockIsHostAllowed).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips credentials, query and fragment from the configured URL', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://user:[email protected]/?a=1#frag';
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('https://umami.example.com/script.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never follows an upstream redirect', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
expect(fetchMock.mock.calls[0][1].redirect).toBe('error');
|
||||||
|
expect(fetchMock.mock.calls[0][1].signal).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rate-limits an unauthenticated client hammering the beacon', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
fetchMock.mockImplementation(async () => upstreamReply('ok', { contentType: 'text/plain' }));
|
||||||
|
const app = buildApp();
|
||||||
|
|
||||||
|
for (let i = 0; i < 120; i += 1) {
|
||||||
|
await request(app).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
}
|
||||||
|
await request(app).get('/api/analytics/tracker/script.js').expect(429);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('502s when the upstream request fails', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(502);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('502s rather than relaying an oversized upstream body', async () => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
fetchMock.mockResolvedValue(upstreamReply('x', {
|
||||||
|
headers: { 'content-length': String(50 * 1024 * 1024) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(502);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('analytics tracker proxy — header and content-type handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
settings.analytics_tracker_provider = 'umami';
|
||||||
|
settings.analytics_umami_url = 'https://umami.example.com';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards the visitor IP and user agent, but not credentials', async () => {
|
||||||
|
await request(buildApp())
|
||||||
|
.get('/api/analytics/tracker/script.js')
|
||||||
|
.set('user-agent', 'Mozilla/5.0 (test)')
|
||||||
|
.set('accept-language', 'de-DE')
|
||||||
|
.set('cookie', 'picpeak_admin_token=secret')
|
||||||
|
.set('authorization', 'Bearer secret')
|
||||||
|
.set('referer', 'https://picpeak.example/gallery/wedding/SHARETOKEN')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const headers = fetchMock.mock.calls[0][1].headers;
|
||||||
|
expect(headers['user-agent']).toBe('Mozilla/5.0 (test)');
|
||||||
|
expect(headers['accept-language']).toBe('de-DE');
|
||||||
|
// req.ip on a supertest connection is loopback — the point is that the
|
||||||
|
// client IP is forwarded at all, so the tracker keeps attributing visits.
|
||||||
|
expect(headers['x-forwarded-for']).toBeTruthy();
|
||||||
|
expect(headers['x-real-ip']).toBe(headers['x-forwarded-for']);
|
||||||
|
expect(Object.keys(headers).map((k) => k.toLowerCase()))
|
||||||
|
.toEqual(expect.not.arrayContaining(['cookie', 'authorization', 'referer', 'host']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('neutralises an HTML response from a hostile tracker host', async () => {
|
||||||
|
// Without this a tracker host could serve `<script>` HTML through
|
||||||
|
// PicPeak's own origin and get it rendered as same-origin content.
|
||||||
|
fetchMock.mockResolvedValue(upstreamReply('<html><body>xss</body></html>', {
|
||||||
|
contentType: 'text/html',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await request(buildApp()).get('/api/analytics/tracker/script.js').expect(200);
|
||||||
|
expect(res.headers['content-type']).toBe('application/octet-stream');
|
||||||
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the upstream status through', async () => {
|
||||||
|
fetchMock.mockResolvedValue(upstreamReply('nope', { status: 404, contentType: 'text/plain' }));
|
||||||
|
await request(buildApp()).get('/api/analytics/tracker/script.js').expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/**
|
||||||
|
* Same-origin proxy for the admin-configured analytics tracker.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ---------------
|
||||||
|
* Settings → Analytics lets an admin point PicPeak at a self-hosted Umami or
|
||||||
|
* Rybbit instance on an arbitrary domain, but the shipped CSP is a static
|
||||||
|
* allowlist (`script-src 'self' https://www.google.com …` in
|
||||||
|
* `frontend/nginx.conf` and in helmet's directives in `server.js`). Injecting
|
||||||
|
* `<script src="https://analytics.example.com/script.js">` was therefore
|
||||||
|
* ALWAYS blocked by the browser — silently, with only a console error, so
|
||||||
|
* "not configured" and "configured but broken" looked identical to the admin.
|
||||||
|
* The tracker's beacon endpoint had the same problem against `connect-src`.
|
||||||
|
*
|
||||||
|
* The CSP itself can't be made dynamic in the default Docker deployment:
|
||||||
|
* nginx serves `index.html` off disk (`try_files … /index.html`) and strips
|
||||||
|
* the backend's CSP with `proxy_hide_header Content-Security-Policy`, so
|
||||||
|
* nginx's static header is the only policy governing the SPA document, and
|
||||||
|
* the tracker URL lives in the DB rather than the environment.
|
||||||
|
*
|
||||||
|
* So instead of widening the policy, we remove the need to: the tracker
|
||||||
|
* script and every endpoint it talks to are served from PicPeak's own origin
|
||||||
|
* and proxied here. `script-src 'self'` and `connect-src 'self'` already
|
||||||
|
* cover that, unchanged. This is the same first-party proxy setup both
|
||||||
|
* vendors document (and recommend — it also survives ad blockers).
|
||||||
|
*
|
||||||
|
* SECURITY MODEL
|
||||||
|
* --------------
|
||||||
|
* The upstream base URL is admin-supplied, so this is an SSRF surface. It is
|
||||||
|
* bounded by:
|
||||||
|
* - scheme restricted to http/https; userinfo (`https://u:p@host`) dropped
|
||||||
|
* by rebuilding from `origin` + `pathname`;
|
||||||
|
* - a DNS-resolving private/internal-address check (`isHostAllowed`) in
|
||||||
|
* production, matching the `s3Storage` precedent — development keeps
|
||||||
|
* working against a localhost tracker;
|
||||||
|
* - a per-provider allowlist of the exact paths each tracker's script
|
||||||
|
* actually calls, so this is not an open relay to the tracker host;
|
||||||
|
* - `redirect: 'error'`, a request timeout, a request-body cap and a
|
||||||
|
* streamed response-body cap;
|
||||||
|
* - a fixed forwarded-header set (never cookies, Authorization or
|
||||||
|
* arbitrary client headers);
|
||||||
|
* - a sanitised response Content-Type plus `nosniff`, so a tracker host
|
||||||
|
* cannot serve HTML/SVG through PicPeak's origin and get it rendered.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
|
const { isHostAllowed } = require('../utils/networkValidation');
|
||||||
|
const { clientIpForAudit } = require('../utils/clientIp');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 5000;
|
||||||
|
// Beacon payloads are a few hundred bytes; 64 KB is generous headroom.
|
||||||
|
const MAX_REQUEST_BYTES = 64 * 1024;
|
||||||
|
// Umami's script.js is ~6 KB; Rybbit's full bundle (rrweb session replay)
|
||||||
|
// is a few hundred KB. 2 MB caps a hostile/broken upstream.
|
||||||
|
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
|
||||||
|
// The settings read happens per beacon, so cache the resolved upstream.
|
||||||
|
// Short enough that saving Settings → Analytics takes effect without a
|
||||||
|
// restart, which is the whole point of not templating this at boot.
|
||||||
|
const CONFIG_TTL_MS = 30 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-provider mapping. `prefix` is what the tracker's own path is relative
|
||||||
|
* to on the upstream, and `routes` is the closed set of method + path pairs
|
||||||
|
* the tracker script is known to call.
|
||||||
|
*
|
||||||
|
* Umami — `<script-dir>/script.js`, collect at `<script-dir>/api/send`
|
||||||
|
* (tracker: `host = data-host-url || currentScript.src` dir,
|
||||||
|
* `endpoint = host + '/api/send'`).
|
||||||
|
* Rybbit — `<prefix>/script.js` upstream `/api/script.js`, and the script
|
||||||
|
* derives `analyticsHost = src.split('/script.js')[0]`, then calls
|
||||||
|
* `<prefix>/track`, `<prefix>/site/tracking-config/<id>` and
|
||||||
|
* `<prefix>/site/<id>/feature-flags/evaluate`. That matches the
|
||||||
|
* mapping in Rybbit's own proxy guide.
|
||||||
|
* Session replay (`<prefix>/session-replay/record/<id>`) is
|
||||||
|
* deliberately NOT proxied: replaying gallery pages would ship the
|
||||||
|
* share token in the recording (GHSA-7m6c).
|
||||||
|
*/
|
||||||
|
const PROVIDERS = {
|
||||||
|
umami: {
|
||||||
|
urlKey: 'analytics_umami_url',
|
||||||
|
prefix: '',
|
||||||
|
routes: [
|
||||||
|
['GET', /^\/script\.js$/],
|
||||||
|
['POST', /^\/api\/send$/],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rybbit: {
|
||||||
|
urlKey: 'analytics_rybbit_url',
|
||||||
|
prefix: '/api',
|
||||||
|
routes: [
|
||||||
|
['GET', /^\/script\.js$/],
|
||||||
|
['POST', /^\/track$/],
|
||||||
|
['GET', /^\/site\/tracking-config\/[A-Za-z0-9_-]{1,64}$/],
|
||||||
|
['POST', /^\/site\/[A-Za-z0-9_-]{1,64}\/feature-flags\/evaluate$/],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client request headers forwarded upstream. `user-agent` and the client IP
|
||||||
|
// are what let the tracker keep doing device/geo attribution once traffic is
|
||||||
|
// first-party; `x-umami-cache` is an opaque session token Umami's own script
|
||||||
|
// echoes back. Everything else — cookies, Authorization, Referer, Origin —
|
||||||
|
// is dropped.
|
||||||
|
const FORWARDED_HEADERS = ['accept', 'accept-language', 'content-type', 'user-agent', 'x-umami-cache'];
|
||||||
|
|
||||||
|
// Response Content-Types we are willing to re-serve from our own origin.
|
||||||
|
// Anything else (text/html, image/svg+xml, …) becomes an inert download.
|
||||||
|
const SAFE_CONTENT_TYPES = new Set([
|
||||||
|
'text/javascript',
|
||||||
|
'application/javascript',
|
||||||
|
'application/json',
|
||||||
|
'text/plain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let cache = { at: 0, value: undefined };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the tracker settings and turn them into `{ base, spec }`, or null when
|
||||||
|
* no proxyable tracker is configured. Mirrors `services/trackers/index.js`'s
|
||||||
|
* back-compat: a pre-#663 install with only `analytics_umami_enabled` set
|
||||||
|
* still resolves to Umami.
|
||||||
|
*/
|
||||||
|
async function loadUpstream() {
|
||||||
|
const explicit = await getAppSetting('analytics_tracker_provider', null);
|
||||||
|
let provider = typeof explicit === 'string' && PROVIDERS[explicit] ? explicit : null;
|
||||||
|
if (!explicit) {
|
||||||
|
const legacy = await getAppSetting('analytics_umami_enabled', false);
|
||||||
|
if (legacy === true || legacy === 'true') provider = 'umami';
|
||||||
|
}
|
||||||
|
if (!provider) return null;
|
||||||
|
|
||||||
|
const spec = PROVIDERS[provider];
|
||||||
|
const raw = await getAppSetting(spec.urlKey, null);
|
||||||
|
if (!raw || typeof raw !== 'string') return null;
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw.trim());
|
||||||
|
} catch {
|
||||||
|
logger.warn('Analytics tracker proxy: configured URL is not a valid URL', { provider });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||||
|
logger.warn('Analytics tracker proxy: refusing non-HTTP tracker URL', {
|
||||||
|
provider,
|
||||||
|
protocol: parsed.protocol,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSRF: resolve-and-vet the host. Prod-only, matching the s3Storage /
|
||||||
|
// MinIO gate — a dev install legitimately points at a localhost tracker,
|
||||||
|
// and a tracker that is only reachable on an internal network could never
|
||||||
|
// have worked from the browser anyway.
|
||||||
|
if (process.env.NODE_ENV === 'production' && !(await isHostAllowed(parsed.hostname))) {
|
||||||
|
logger.warn('Analytics tracker proxy: tracker host resolves to a private or internal address', {
|
||||||
|
provider,
|
||||||
|
host: parsed.hostname,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild from origin + pathname: drops any userinfo, query and fragment
|
||||||
|
// the admin may have pasted along with the base URL.
|
||||||
|
const base = `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
||||||
|
return { base, spec };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUpstream() {
|
||||||
|
if (cache.value !== undefined && Date.now() - cache.at < CONFIG_TTL_MS) {
|
||||||
|
return cache.value;
|
||||||
|
}
|
||||||
|
const value = await loadUpstream();
|
||||||
|
cache = { at: Date.now(), value };
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain an upstream response body, aborting once it exceeds `max` bytes so a
|
||||||
|
* hostile or broken tracker can't stream us out of memory.
|
||||||
|
*/
|
||||||
|
async function readBounded(response, max) {
|
||||||
|
const declared = Number(response.headers.get('content-length'));
|
||||||
|
if (Number.isFinite(declared) && declared > max) return null;
|
||||||
|
|
||||||
|
if (!response.body) return Buffer.alloc(0);
|
||||||
|
const chunks = [];
|
||||||
|
let total = 0;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.length;
|
||||||
|
if (total > max) {
|
||||||
|
await reader.cancel();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chunks.push(Buffer.from(value));
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeContentType(raw) {
|
||||||
|
const base = String(raw || '').split(';')[0].trim().toLowerCase();
|
||||||
|
return SAFE_CONTENT_TYPES.has(base)
|
||||||
|
? `${base}; charset=utf-8`
|
||||||
|
: 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gallery visitors are anonymous, so this route has to be unauthenticated —
|
||||||
|
// cap how hard one client can make PicPeak fetch from the tracker. A real
|
||||||
|
// visitor fires a handful of beacons a minute; this only bites abuse. Its own
|
||||||
|
// limiter rather than the app-wide one because that is created asynchronously
|
||||||
|
// after the database is up, long after this router is mounted.
|
||||||
|
router.use(rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 120,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (req, res) => res.sendStatus(429),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Raw body: mounted before the app-wide express.json so the tracker's beacon
|
||||||
|
// payload (JSON, or text/plain from navigator.sendBeacon) reaches us intact.
|
||||||
|
router.use(express.raw({ type: () => true, limit: MAX_REQUEST_BYTES }));
|
||||||
|
|
||||||
|
router.all('*', async (req, res) => {
|
||||||
|
const upstream = await resolveUpstream();
|
||||||
|
if (!upstream) return res.sendStatus(404);
|
||||||
|
|
||||||
|
const path = req.path;
|
||||||
|
const allowed = upstream.spec.routes.some(([method, re]) => method === req.method && re.test(path));
|
||||||
|
if (!allowed) return res.sendStatus(404);
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
for (const name of FORWARDED_HEADERS) {
|
||||||
|
const value = req.get(name);
|
||||||
|
if (value) headers[name] = value;
|
||||||
|
}
|
||||||
|
// Let the tracker keep attributing visitors now that every request arrives
|
||||||
|
// from PicPeak's server. req.ip (not the raw header) so Express's
|
||||||
|
// trust-proxy configuration decides what is trustworthy.
|
||||||
|
const ip = clientIpForAudit(req);
|
||||||
|
if (ip) {
|
||||||
|
headers['x-forwarded-for'] = ip;
|
||||||
|
headers['x-real-ip'] = ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.isBuffer(req.body) && req.body.length ? req.body : undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
// The timeout deliberately stays armed across the body read too, so a
|
||||||
|
// slow-loris upstream can't pin a request open past REQUEST_TIMEOUT_MS.
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
let buffer;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${upstream.base}${upstream.spec.prefix}${path}`, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
// Never follow a redirect: an upstream 30x would move the request (and
|
||||||
|
// the visitor's forwarded IP) to a host that never passed the checks
|
||||||
|
// above.
|
||||||
|
redirect: 'error',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
buffer = await readBounded(response, MAX_RESPONSE_BYTES);
|
||||||
|
if (buffer === null) {
|
||||||
|
logger.warn('Analytics tracker proxy: upstream response exceeds the size cap', { path });
|
||||||
|
return res.sendStatus(502);
|
||||||
|
}
|
||||||
|
res.status(response.status);
|
||||||
|
res.setHeader('Content-Type', safeContentType(response.headers.get('content-type')));
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Analytics tracker proxy: upstream request failed', {
|
||||||
|
path,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
return res.sendStatus(502);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
// Only the tracker script is safe to cache; beacons and per-site config
|
||||||
|
// responses never are.
|
||||||
|
res.setHeader('Cache-Control', path === '/script.js' ? 'public, max-age=300' : 'no-store');
|
||||||
|
return res.send(buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -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;
|
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
|
# API proxy
|
||||||
location /api {
|
location /api {
|
||||||
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { Button, Card, Input } from '../../../components/common';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { AnalyticsSettings, TrackerProvider } from '../hooks/useSettingsState';
|
import type { AnalyticsSettings, TrackerProvider } from '../hooks/useSettingsState';
|
||||||
@@ -16,10 +16,38 @@ interface AnalyticsTabProps {
|
|||||||
const PROVIDER_OPTIONS: TrackerProvider[] = ['none', 'umami', 'rybbit', 'custom'];
|
const PROVIDER_OPTIONS: TrackerProvider[] = ['none', 'umami', 'rybbit', 'custom'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shipped CSP `script-src` is a static allowlist that no configured
|
* Umami and Rybbit are loaded through PicPeak's own origin (the backend
|
||||||
* tracker domain is ever added to, so a self-hosted Umami/Rybbit instance is
|
* proxies `/api/analytics/tracker/*` to the URL configured above), so the
|
||||||
* blocked by the browser with nothing but a console error to show for it.
|
* shipped `script-src 'self'` / `connect-src 'self'` CSP already covers both
|
||||||
* Shown for every provider that loads a script from another origin.
|
* 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 CspWarning: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -34,8 +62,12 @@ const CspWarning: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
'settings.analytics.customCspWarningText',
|
// Deliberately a NEW key: the old `customCspWarningText` value is
|
||||||
'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.',
|
// 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +190,7 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CspWarning />
|
<ProxiedNotice />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -223,7 +255,7 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CspWarning />
|
<ProxiedNotice />
|
||||||
</div>
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,8 +10,25 @@
|
|||||||
// Custom → render admin-pasted HTML (sanitised server-side) into <head>;
|
// Custom → render admin-pasted HTML (sanitised server-side) into <head>;
|
||||||
// no runtime API hook — `track()` becomes a no-op.
|
// no runtime API hook — `track()` becomes a no-op.
|
||||||
|
|
||||||
|
import { getApiBaseUrl } from '../utils/url';
|
||||||
|
|
||||||
export type TrackerProvider = 'none' | 'umami' | 'rybbit' | 'custom';
|
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 {
|
interface BaseInitConfig {
|
||||||
provider: TrackerProvider;
|
provider: TrackerProvider;
|
||||||
autoTrack?: boolean;
|
autoTrack?: boolean;
|
||||||
@@ -83,10 +100,16 @@ class AnalyticsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.websiteId = config.websiteId;
|
this.websiteId = config.websiteId;
|
||||||
|
const proxyBase = trackerProxyBase();
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.defer = 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);
|
script.setAttribute('data-website-id', config.websiteId);
|
||||||
// Auto-track OFF by default (GHSA-7m6c): Umami's auto page-view capture
|
// Auto-track OFF by default (GHSA-7m6c): Umami's auto page-view capture
|
||||||
// reads window.location verbatim, so a gallery URL /gallery/:slug/:token
|
// reads window.location verbatim, so a gallery URL /gallery/:slug/:token
|
||||||
@@ -106,7 +129,10 @@ class AnalyticsService {
|
|||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.async = true;
|
script.async = true;
|
||||||
script.defer = 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);
|
script.setAttribute('data-site-id', config.websiteId);
|
||||||
// GHSA-7m6c: Rybbit auto-tracks page views (initial load + SPA route
|
// GHSA-7m6c: Rybbit auto-tracks page views (initial load + SPA route
|
||||||
// changes) reading window.location, so a gallery URL would ship the raw
|
// changes) reading window.location, so a gallery URL would ship the raw
|
||||||
|
|||||||
Reference in New Issue
Block a user