Files
webmail/lib/telemetry/sender.ts
T
Linus Rath 4bbcf9cdd7 fix: check telemetry targets at connect time and fail closed
The telemetry sender and the Stalwart version probe checked the target
with their own DNS lookup and then fetched with a plain `fetch`, which
resolved the name again: a rebinding answer between the two reached an
internal host, and a lookup that failed at check time passed the check
outright. Both now connect through `fetchPublicUrl`, which validates
the address inside the socket's own lookup and fails when the lookup
does. The `BULWARK_TELEMETRY_ALLOW_PRIVATE` dev bypass keeps the plain
fetch.
2026-09-26 15:15:23 +02:00

111 lines
3.9 KiB
TypeScript

import { logger } from '@/lib/logger';
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
import { buildPayload } from './payload';
import { fetchTelemetryTarget, resolveEndpointAllowed } from './endpoint-guard';
import { DEFAULT_ENDPOINT } from './types';
const DAY_MS = 24 * 60 * 60 * 1000;
const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours
const FIRST_DELAY_MS = 60 * 60 * 1000; // 1 hour after consent
let currentTimer: NodeJS.Timeout | null = null;
function jitteredDelay(base: number): number {
const j = (Math.random() * 2 - 1) * JITTER_MS;
return Math.max(60_000, base + j);
}
export async function sendOnce(opts?: { reason?: string }): Promise<{
ok: boolean;
status?: number;
error?: string;
}> {
const { consent, source, state } = await effectiveConsent();
if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` };
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
// Re-check at fetch time: defeats DNS rebinding, and catches the case
// where state.json was edited out-of-band to bypass the admin API.
const guard = await resolveEndpointAllowed(endpoint);
if (!guard.ok) {
logger.warn('telemetry: endpoint blocked', { reason: guard.reason });
return { ok: false, error: `endpoint blocked: ${guard.reason}` };
}
const payload = await buildPayload();
try {
// Never follows redirects: the endpoint guard only vetted the URL we
// were given, so a 3xx to an internal host would bypass it.
const res = await fetchTelemetryTarget(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
});
const ok = res.ok;
if (ok) {
const next = await loadState();
next.lastSentAt = new Date().toISOString();
await saveState(next);
}
logger.info('telemetry: heartbeat', {
ok, status: res.status, reason: opts?.reason ?? 'scheduled',
});
return { ok, status: res.status };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn('telemetry: heartbeat failed', { error: msg });
return { ok: false, error: msg };
}
}
async function scheduleNext(delayMs: number): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
const at = new Date(Date.now() + delayMs).toISOString();
const state = await loadState();
state.nextScheduledAt = at;
await saveState(state);
currentTimer = setTimeout(() => { void tick(); }, delayMs);
// Don't keep the process alive just for this.
currentTimer.unref?.();
}
async function tick(): Promise<void> {
await sendOnce({ reason: 'scheduled' });
await scheduleNext(jitteredDelay(DAY_MS));
}
// Called from instrumentation. Idempotent.
export async function startScheduler(): Promise<void> {
const { consent } = await effectiveConsent();
if (consent !== 'on') {
logger.info('telemetry: scheduler not started', { consent });
return;
}
const state = await loadState();
// If we have a next-scheduled time in the future use it; otherwise schedule
// FIRST_DELAY_MS out. This means after a restart we don't fire immediately.
let delay = FIRST_DELAY_MS;
if (state.nextScheduledAt) {
const remaining = new Date(state.nextScheduledAt).getTime() - Date.now();
if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS);
}
await scheduleNext(delay);
logger.info('telemetry: scheduler started', {
nextInMs: delay,
endpoint: state.endpoint,
});
}
export async function stopScheduler(): Promise<void> {
if (currentTimer) clearTimeout(currentTimer);
currentTimer = null;
}
// Called when consent flips on/off via the UI.
export async function reschedule(): Promise<void> {
await stopScheduler();
await startScheduler();
}