Step 11 av v4.1-execute (Wave 2, Session 3).
3 sikkerhets-validatorer for OTel-eksporten:
path-validator.mjs (CWE-22 Path Traversal):
- Reject `..` segmenter, `~`-shorthand
- realpathSync symlink-resolution (med macOS quirk: /etc, /var, /tmp er
symlinks til /private/etc, /private/var, /private/tmp — begge former
i FORBIDDEN_PREFIXES)
- Allowlist-først evaluering: hvis allowedRoots gitt, det er primary defense
(caller's threat model). Forbidden-prefix-denylist er FALLBACK når
allowedRoots ikke spesifisert.
endpoint-validator.mjs (CWE-918 SSRF):
- Reject loopback (127.0.0.1, ::1, localhost, 0.0.0.0) UNLESS VOYAGE_OTEL_ALLOW_PRIVATE=1
- Reject RFC-1918 (10/8, 172.16/12, 192.168/16) UNLESS opt-in
- Reject link-local (169.254.x.x cloud metadata, fe80:* IPv6) UNLESS opt-in
- Krev https:// for non-private endpoints
- node:url-parsing, ingen runtime DNS-resolusjon (defense-in-depth)
field-allowlist.mjs (CWE-212 Improper Cross-boundary Removal of Sensitive Data):
- INLINE static const Object.freeze på modul-scope (IKKE runtime read fra fixtures)
- Per-schema allowlist for alle 8 schema-id (trekbrief, trekresearch, trekplan,
trekexecute, event-emit, post-bash-stats, trekreview, trekcontinue)
- Source-comment per allowlist refererer tests/fixtures/jsonl-schemas.md
- post-bash-stats DROPPER eksplisitt command_excerpt + session_id (CWE-212)
- event-emit applies sub-allowlist på payload-objekt (recursive)
- Unknown schema-type returnerer conservative {_schema_id, ts}
Tester (19 nye, baseline 413 → 432):
- path-validator x6 (CWE-22 traversal, forbidden-system, ~, allowedRoots accept/reject, drift-pin)
- endpoint-validator x7 (CWE-918 link-local, RFC-1918, loopback, https-required, opt-in, public-accept, empty-input)
- field-allowlist x6 (CWE-212 post-bash-stats, trekplan-PII, event-emit-payload, unknown-schema, Object.freeze, null-safe)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
91 lines
3 KiB
JavaScript
91 lines
3 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:'];
|
|
|
|
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 };
|