fix: block telemetry endpoint from pointing at internal hosts
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
|||||||
reschedule,
|
reschedule,
|
||||||
DEFAULT_ENDPOINT,
|
DEFAULT_ENDPOINT,
|
||||||
getLoginCounts,
|
getLoginCounts,
|
||||||
|
resolveEndpointAllowed,
|
||||||
} from '@/lib/telemetry';
|
} from '@/lib/telemetry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,8 +101,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const trimmed = body.endpoint.trim();
|
const trimmed = body.endpoint.trim();
|
||||||
if (trimmed && !/^https?:\/\//i.test(trimmed)) {
|
if (trimmed) {
|
||||||
return NextResponse.json({ error: 'endpoint must be http(s)://' }, { status: 400 });
|
const check = await resolveEndpointAllowed(trimmed);
|
||||||
|
if (!check.ok) {
|
||||||
|
return NextResponse.json({ error: check.reason }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const state = await loadState();
|
const state = await loadState();
|
||||||
const before = state.endpoint;
|
const before = state.endpoint;
|
||||||
|
|||||||
@@ -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<EndpointCheck> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ export {
|
|||||||
loadState, saveState, getInstanceId, effectiveConsent,
|
loadState, saveState, getInstanceId, effectiveConsent,
|
||||||
} from './state';
|
} from './state';
|
||||||
export { recordLogin, getLoginCounts } from './login-tracker';
|
export { recordLogin, getLoginCounts } from './login-tracker';
|
||||||
|
export {
|
||||||
|
validateEndpointUrl, resolveEndpointAllowed, isPrivateAddress,
|
||||||
|
} from './endpoint-guard';
|
||||||
|
export type { EndpointCheck } from './endpoint-guard';
|
||||||
export type {
|
export type {
|
||||||
TelemetryPayload, TelemetryStateFile, ConsentState,
|
TelemetryPayload, TelemetryStateFile, ConsentState,
|
||||||
Platform, OsFamily, CountBucket, TelemetryFeatures,
|
Platform, OsFamily, CountBucket, TelemetryFeatures,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
|
import { effectiveConsent, endpointEnabled, loadState, saveState } from './state';
|
||||||
import { buildPayload } from './payload';
|
import { buildPayload } from './payload';
|
||||||
|
import { resolveEndpointAllowed } from './endpoint-guard';
|
||||||
import { DEFAULT_ENDPOINT } from './types';
|
import { DEFAULT_ENDPOINT } from './types';
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
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;
|
const endpoint = state.endpoint || DEFAULT_ENDPOINT;
|
||||||
if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' };
|
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();
|
const payload = await buildPayload();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
|
|||||||
Reference in New Issue
Block a user