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
This commit is contained in:
parent
6ed0c27fe2
commit
b208e4ee04
5 changed files with 88 additions and 3 deletions
|
|
@ -42,6 +42,28 @@ 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.
|
||||
*
|
||||
|
|
@ -69,7 +91,12 @@ export function validateOtlpEndpoint(url, opts = {}) {
|
|||
}
|
||||
|
||||
// Strip brackets from IPv6
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue