diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts index 2bbab5af..a08e7656 100644 --- a/app/api/admin/telemetry/route.ts +++ b/app/api/admin/telemetry/route.ts @@ -11,6 +11,7 @@ import { reschedule, DEFAULT_ENDPOINT, getLoginCounts, + resolveEndpointAllowed, } from '@/lib/telemetry'; /** @@ -100,8 +101,11 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'endpoint required' }, { status: 400 }); } const trimmed = body.endpoint.trim(); - if (trimmed && !/^https?:\/\//i.test(trimmed)) { - return NextResponse.json({ error: 'endpoint must be http(s)://' }, { status: 400 }); + if (trimmed) { + const check = await resolveEndpointAllowed(trimmed); + if (!check.ok) { + return NextResponse.json({ error: check.reason }, { status: 400 }); + } } const state = await loadState(); const before = state.endpoint; diff --git a/lib/telemetry/endpoint-guard.ts b/lib/telemetry/endpoint-guard.ts new file mode 100644 index 00000000..529f160b --- /dev/null +++ b/lib/telemetry/endpoint-guard.ts @@ -0,0 +1,115 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; + +// Block telemetry endpoints from pointing at internal/loopback addresses. +// Required because the admin UI lets an authenticated admin set an arbitrary +// URL; without this an attacker with a session (or a hostile admin in a +// multi-tenant deploy) could redirect heartbeats at internal hosts. +// +// Set BULWARK_TELEMETRY_ALLOW_PRIVATE=1 to bypass — useful only for local +// dev where the collector is on the loopback. + +const PRIVATE_V4: RegExp[] = [ + /^0\./, // 0.0.0.0/8 + /^10\./, // 10.0.0.0/8 + /^127\./, // loopback + /^169\.254\./, // link-local + cloud metadata + /^172\.(1[6-9]|2\d|3[0-1])\./, // 172.16.0.0/12 + /^192\.168\./, // 192.168.0.0/16 + /^192\.0\.0\./, // IETF reserved + /^198\.(1[8-9])\./, // benchmarking 198.18.0.0/15 + /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // 100.64.0.0/10 CGNAT + /^22[4-9]\./, // 224.0.0.0/4 multicast + /^23\d\./, + /^2[4-5]\d\./, // 240.0.0.0/4 reserved +]; + +function isPrivateV4(ip: string): boolean { + return PRIVATE_V4.some((re) => re.test(ip)); +} + +function isPrivateV6(ip: string): boolean { + const lower = ip.toLowerCase(); + if (lower === '::1' || lower === '::') return true; + if (/^fe[89ab][0-9a-f]:/.test(lower)) return true; // fe80::/10 link-local + if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; // fc00::/7 ULA + const mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) return isPrivateV4(mapped[1]); + return false; +} + +export function isPrivateAddress(ip: string): boolean { + const family = isIP(ip); + if (family === 4) return isPrivateV4(ip); + if (family === 6) return isPrivateV6(ip); + return false; +} + +const BAD_HOSTS = new Set([ + 'localhost', + 'localhost.localdomain', + 'ip6-localhost', + 'ip6-loopback', +]); + +function bypassEnabled(): boolean { + return process.env.BULWARK_TELEMETRY_ALLOW_PRIVATE === '1'; +} + +export type EndpointCheck = { ok: true } | { ok: false; reason: string }; + +// Sync URL/host shape check. Catches the obvious cases without DNS. +export function validateEndpointUrl(raw: string): EndpointCheck { + let url: URL; + try { + url = new URL(raw); + } catch { + return { ok: false, reason: 'invalid URL' }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return { ok: false, reason: 'must be http(s)://' }; + } + if (bypassEnabled()) return { ok: true }; + + const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (!host) return { ok: false, reason: 'host required' }; + if (BAD_HOSTS.has(host)) { + return { ok: false, reason: 'localhost endpoints are not allowed' }; + } + if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.localhost')) { + return { ok: false, reason: 'private TLDs are not allowed' }; + } + if (isIP(host) && isPrivateAddress(host)) { + return { ok: false, reason: 'private/loopback IP is not allowed' }; + } + return { ok: true }; +} + +// Async check that additionally resolves DNS hostnames. Use this on +// set-endpoint AND immediately before fetch to defeat DNS-rebinding tricks +// where a hostname resolves to a public IP at validation time and a private +// one at fetch time. +export async function resolveEndpointAllowed(raw: string): Promise { + const initial = validateEndpointUrl(raw); + if (!initial.ok) return initial; + if (bypassEnabled()) return { ok: true }; + + const host = new URL(raw).hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if (isIP(host)) return { ok: true }; + + try { + const addrs = await lookup(host, { all: true }); + for (const a of addrs) { + if (isPrivateAddress(a.address)) { + return { ok: false, reason: `host ${host} resolves to private address ${a.address}` }; + } + } + return { ok: true }; + } catch { + // Don't block on transient DNS failures — fetch will fail loudly anyway, + // and we don't want to lock admins out of their config when the resolver + // is flaky. The literal-IP check above already covers the direct-attack + // case. + return { ok: true }; + } +} diff --git a/lib/telemetry/index.ts b/lib/telemetry/index.ts index bdc8ae11..1717c1bf 100644 --- a/lib/telemetry/index.ts +++ b/lib/telemetry/index.ts @@ -4,6 +4,10 @@ export { loadState, saveState, getInstanceId, effectiveConsent, } from './state'; export { recordLogin, getLoginCounts } from './login-tracker'; +export { + validateEndpointUrl, resolveEndpointAllowed, isPrivateAddress, +} from './endpoint-guard'; +export type { EndpointCheck } from './endpoint-guard'; export type { TelemetryPayload, TelemetryStateFile, ConsentState, Platform, OsFamily, CountBucket, TelemetryFeatures, diff --git a/lib/telemetry/sender.ts b/lib/telemetry/sender.ts index 675b3e93..ed05a00e 100644 --- a/lib/telemetry/sender.ts +++ b/lib/telemetry/sender.ts @@ -1,6 +1,7 @@ import { logger } from '@/lib/logger'; import { effectiveConsent, endpointEnabled, loadState, saveState } from './state'; import { buildPayload } from './payload'; +import { resolveEndpointAllowed } from './endpoint-guard'; import { DEFAULT_ENDPOINT } from './types'; const DAY_MS = 24 * 60 * 60 * 1000; @@ -24,6 +25,14 @@ export async function sendOnce(opts?: { reason?: string }): Promise<{ 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 { const res = await fetch(endpoint, {