voyage/lib/exporters/endpoint-validator.mjs
Kjell Tore Guttormsen b208e4ee04 fix(voyage): S21 — close IPv4-mapped IPv6 SSRF bypass + security/safety audit (blind spot #2)
S21 = devil's-advocate blind spot #2 (security/safety), operator scope
"security-core": fix genuine defects, honestly record the rest.

SSRF fix (CWE-918). validateOtlpEndpoint classified the host by literal
string match. Decimal/hex/octal/trailing-dot encodings are already caught
(the WHATWG URL parser canonicalizes them), but IPv4-mapped IPv6 literals
(::ffff:127.0.0.1, ::ffff:192.168.x, ::ffff:169.254.169.254) render as
::ffff:HHHH:HHHH and matched neither the loopback set, the RFC-1918 regex,
link-local, nor HARD_BLOCKED_HOSTS — they passed over https and would reach
loopback / private / cloud-metadata, defeating the comment's "PERMANENTLY
block metadata" promise. Added mappedV4() to decode the embedded IPv4
(dotted + hex-pair forms) and classify on it. TDD: 4 tests failing-first —
mapped loopback/RFC-1918/metadata rejected (metadata stays HARD_BLOCKED even
with VOYAGE_OTEL_ALLOW_PRIVATE=1), mapped public ::ffff:8.8.8.8 still valid
(no over-block). Threat model narrow (endpoint is operator-set env; export
opt-in; a brief cannot set env).

Survivor #18 honest-residual. T2-bakeoff §3 "Classifier interference: 0"
read as satisfied, but the auto/bypass-mode re-run that matters for headless
trekreview was never run (mode is operator-set, not settable in-session) —
footnoted, not gating. Per recommendation #9, moved to an OPEN RESIDUAL
(untested), guarded by a new doc-consistency pin.

Audit record. ## S21 resolution block in devils-advocate-results.md
dispositions all four sub-questions: SSRF (fixed), hooks-block (verified;
advisory-rail residuals: Write-only matcher, Bash-redirect, regex gaps — by
design), malicious-brief-headless (catastrophe-blocked, exfil NOT blocked —
inherent limit). S21b flagged (NOT done): operations.md:15 mis-describes
autonomy-gate.mjs state machine + --gates table.

Test count: real baseline 700 (698 pass / 2 skip), NOT 715 — the node:test
headline carried in S20 commit msgs was stale/miscounted (census figures
were correct). Now 705 (703 pass / 2 skip / 0 fail); census behavior
601->605, doc-pins 71->72, total 672->677.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
2026-06-19 20:02:56 +02:00

139 lines
5.2 KiB
JavaScript

// 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:'];
// Cloud metadata service endpoints — PERMANENTLY blocked even with
// VOYAGE_OTEL_ALLOW_PRIVATE=1. These addresses expose IAM credentials,
// instance secrets, and user-data on AWS/GCP/Azure/AliCloud workloads.
// Operator-trust is NOT extended to these specific IPs because the
// blast-radius (cloud-account compromise) is qualitatively different
// from home-lab RFC-1918 access.
const HARD_BLOCKED_HOSTS = new Set([
'169.254.169.254', // AWS / GCP / Azure metadata service
'100.100.100.200', // AliCloud metadata service
'metadata.google.internal',
'metadata.azure.com',
]);
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));
}
// IPv4-mapped IPv6 (::ffff:a.b.c.d) routes to the embedded IPv4. Node renders
// the literal as ::ffff:HHHH:HHHH, so decode the embedded IPv4 and classify on
// THAT — otherwise the loopback / RFC-1918 / link-local / cloud-metadata guards
// below are all bypassable via the mapped form over https (CWE-918, S21).
// Returns dotted-decimal IPv4, or null when host is not an IPv4-mapped literal.
function mappedV4(host) {
const m = /^::ffff:(.+)$/i.exec(host);
if (!m) return null;
const rest = m[1];
if (rest.includes('.')) {
// Dotted form: ::ffff:127.0.0.1
return /^\d{1,3}(\.\d{1,3}){3}$/.test(rest) ? rest : null;
}
// Hex-pair form: ::ffff:7f00:1 → two 16-bit groups → 4 octets
const parts = rest.split(':');
if (parts.length !== 2) return null;
const hi = parseInt(parts[0], 16);
const lo = parseInt(parts[1], 16);
if (!Number.isInteger(hi) || !Number.isInteger(lo) || hi > 0xffff || lo > 0xffff) return null;
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
}
/**
* 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
let host = parsed.hostname.replace(/^\[|\]$/g, '');
// Canonicalize IPv4-mapped IPv6 to its embedded IPv4 so every guard below
// classifies (and reports) the address the request would actually reach.
const embedded = mappedV4(host);
if (embedded) host = embedded;
// Cloud metadata services — PERMANENTLY blocked. VOYAGE_OTEL_ALLOW_PRIVATE
// does NOT override this; metadata endpoints expose IAM credentials.
if (HARD_BLOCKED_HOSTS.has(host)) {
return fail(issue('ENDPOINT_HARD_BLOCKED',
`Endpoint ${host} is permanently blocked (cloud metadata service). ` +
`VOYAGE_OTEL_ALLOW_PRIVATE does not override this restriction.`));
}
// Other link-local addresses — 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 };