Adds a once-per-day heartbeat that lets the project see how many instances run Bulwark, on what platforms, with what features enabled, and roughly how many accounts they have. No email addresses, hostnames, IPs, or any end-user data are ever sent. - lib/telemetry: state file, payload builder, jittered scheduler, instance_id persistence at <data-dir>/.telemetry-id (delete to reset) - app/api/admin/telemetry: admin API for status / set-consent / set-endpoint / send-now (all audit-logged) - app/admin/telemetry: settings page with status, JSON payload preview, endpoint editor, send-now button, link to the privacy page - instrumentation.node.ts: starts the scheduler on boot Default state is enabled. The first heartbeat fires 1 hour after boot so an admin who installs and immediately disables produces zero pings. Disable via the settings UI, BULWARK_TELEMETRY=off (or BULWARK_TELEMETRY_DISABLED=1), or by clearing the endpoint. Account counts are bucketed (1, 2-5, 6-10, 11-50, 51-200, 201+) so a small instance can't be re-identified by exact size. The /.telemetry-id file can be deleted to mint a fresh instance_id. Receiving collector is open source at bulwarkmail/dashboard. Self-host your own and point at it via BULWARK_TELEMETRY_URL. Full schema, retention (90d raw → aggregates), and lawful basis are documented at bulwarkmail.org/docs/legal/privacy/telemetry.
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
/**
|
|
* Computes the union of CSP `frame-src` origins declared by installed and
|
|
* enabled plugins. The proxy reads this on each request so that plugins can
|
|
* embed external content (YouTube, Vimeo, Jitsi, …) without us hard-coding
|
|
* domains in the host CSP.
|
|
*
|
|
* Origins are validated at install time and re-validated here as defense in
|
|
* depth - any malformed value is dropped so a corrupted registry can never
|
|
* inject arbitrary CSP fragments.
|
|
*/
|
|
|
|
import { getPluginRegistry } from './plugin-registry';
|
|
|
|
// `https://host`, `https://host:port`, or `https://*.host[:port]`
|
|
//
|
|
// Each label is alphanumeric with optional inner dashes; the final TLD label
|
|
// MUST start with a letter so we reject raw IPv4 literals.
|
|
//
|
|
// Disallowed by the regex (intentionally):
|
|
// - any scheme other than https
|
|
// - paths, queries, fragments
|
|
// - userinfo, IPv4 literals, IPv6 literals (`[::1]`)
|
|
// - bare wildcards (`https://*`)
|
|
const FRAME_ORIGIN_RE =
|
|
/^https:\/\/(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*\.(?:[a-z](?:[a-z0-9-]*[a-z0-9])?)(?::[0-9]{1,5})?$/i;
|
|
|
|
export function isValidFrameOrigin(origin: unknown): origin is string {
|
|
if (typeof origin !== 'string') return false;
|
|
if (origin.length > 200) return false;
|
|
if (!FRAME_ORIGIN_RE.test(origin)) return false;
|
|
// Reject control characters / whitespace as a final safeguard against
|
|
// anything that would let an attacker break out of the directive.
|
|
if (/[\s'"`;,()]/.test(origin)) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Sanitises a list of candidate origins from a manifest. Drops invalid
|
|
* entries silently and dedupes (case-insensitive on the host).
|
|
*/
|
|
export function sanitizeFrameOrigins(input: unknown): string[] {
|
|
if (!Array.isArray(input)) return [];
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const value of input) {
|
|
if (!isValidFrameOrigin(value)) continue;
|
|
const key = value.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// In-memory cache. The proxy fires on every page navigation; reading the
|
|
// registry JSON every time is fine but cheap to skip when nothing has
|
|
// changed. Five seconds is short enough to make plugin install/uninstall
|
|
// feel snappy without measurable overhead.
|
|
let cachedAt = 0;
|
|
let cachedOrigins: string[] = [];
|
|
const CACHE_TTL_MS = 5_000;
|
|
|
|
/**
|
|
* Returns the union of frame origins declared by every enabled plugin in
|
|
* the server-side registry, deduped and validated.
|
|
*
|
|
* Returns an empty array on any failure (missing file, parse error, …) so
|
|
* a broken registry only ever shrinks the CSP - never widens it.
|
|
*/
|
|
export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
|
|
const now = Date.now();
|
|
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
|
|
|
|
try {
|
|
const registry = await getPluginRegistry();
|
|
const seen = new Set<string>();
|
|
const out: string[] = [];
|
|
for (const plugin of registry.plugins) {
|
|
if (!plugin.enabled) continue;
|
|
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
|
|
for (const o of origins) {
|
|
const key = o.toLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push(o);
|
|
}
|
|
}
|
|
cachedOrigins = out;
|
|
cachedAt = now;
|
|
return out;
|
|
} catch {
|
|
cachedOrigins = [];
|
|
cachedAt = now;
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Force the next call to re-read the registry. Used by install/uninstall. */
|
|
export function invalidateFrameOriginsCache(): void {
|
|
cachedAt = 0;
|
|
cachedOrigins = [];
|
|
}
|