// lib/exporters/endpoint-validator.mjs // Validate OTLP/HTTP endpoint URLs for the OTel exporter. // // CWE-918 (Server-Side Request Forgery) mitigation: reject loopback, RFC-1918, // link-local (cloud metadata 169.254.169.254), and require HTTPS for non-loopback. // Operator opt-in for private endpoints via VOYAGE_OTEL_ALLOW_PRIVATE=1 // (legitimate home-lab / docker-compose operator scenario). import { ok, fail, issue } from '../util/result.mjs'; const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost', '0.0.0.0']); const LINK_LOCAL_PREFIXES = ['169.254.', 'fe80:']; function isRfc1918(host) { // 10.0.0.0/8 if (/^10\./.test(host)) return true; // 172.16.0.0/12 if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) return true; // 192.168.0.0/16 if (/^192\.168\./.test(host)) return true; return false; } function isLoopback(host) { return LOOPBACK_HOSTS.has(host); } function isLinkLocal(host) { return LINK_LOCAL_PREFIXES.some(p => host.startsWith(p)); } /** * Validate an OTLP/HTTP endpoint URL. * * @param {string} url * @param {{env?: object}} [opts] * @returns {import('../util/result.mjs').Result} */ export function validateOtlpEndpoint(url, opts = {}) { const env = opts.env || process.env; const allowPrivate = env.VOYAGE_OTEL_ALLOW_PRIVATE === '1'; if (typeof url !== 'string' || url.length === 0) { return fail(issue('ENDPOINT_EMPTY', 'Endpoint must be a non-empty string')); } let parsed; try { parsed = new URL(url); } catch (e) { return fail(issue('ENDPOINT_PARSE_ERROR', `Invalid URL: ${e.message}`)); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return fail(issue('ENDPOINT_BAD_PROTOCOL', `Endpoint protocol must be http or https, got ${parsed.protocol}`)); } // Strip brackets from IPv6 const host = parsed.hostname.replace(/^\[|\]$/g, ''); // Cloud metadata IP (link-local) — always rejected unless explicit opt-in if (isLinkLocal(host) && !allowPrivate) { return fail(issue('ENDPOINT_LINK_LOCAL_REJECTED', `Link-local endpoint ${host} rejected (potential cloud-metadata access). ` + `Set VOYAGE_OTEL_ALLOW_PRIVATE=1 to allow.`)); } // Loopback / RFC-1918 — rejected unless opt-in if (isLoopback(host) && !allowPrivate) { return fail(issue('ENDPOINT_LOOPBACK_REJECTED', `Loopback endpoint ${host} rejected. Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for ` + `home-lab / docker-compose scenarios.`)); } if (isRfc1918(host) && !allowPrivate) { return fail(issue('ENDPOINT_RFC1918_REJECTED', `RFC-1918 private endpoint ${host} rejected. ` + `Set VOYAGE_OTEL_ALLOW_PRIVATE=1 for home-lab scenarios.`)); } // For non-loopback, non-private endpoints: require HTTPS const isPrivate = isLoopback(host) || isRfc1918(host) || isLinkLocal(host); if (!isPrivate && parsed.protocol === 'http:') { return fail(issue('ENDPOINT_HTTPS_REQUIRED', `Public endpoint ${host} requires https:// (got http://)`)); } return ok({ url: parsed.href, host, isPrivate }); } export { LOOPBACK_HOSTS, LINK_LOCAL_PREFIXES };