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
|
|
@ -135,6 +135,35 @@ test('endpoint-validator: rejects RFC-1918 192.168.1.1 without opt-in', () => {
|
|||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_RFC1918_REJECTED'));
|
||||
});
|
||||
|
||||
// IPv4-mapped IPv6 bypass (S21): `::ffff:a.b.c.d` literals render as
|
||||
// `::ffff:HHHH:HHHH` and route to the embedded IPv4. The host must be
|
||||
// canonicalized to its embedded IPv4 BEFORE classification, or loopback /
|
||||
// RFC-1918 / link-local / cloud-metadata guards are all bypassable over https.
|
||||
test('endpoint-validator: rejects IPv4-mapped IPv6 loopback [::ffff:127.0.0.1] (S21 bypass)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:127.0.0.1]/v1/metrics', { env: {} });
|
||||
assert.equal(r.valid, false, 'IPv4-mapped loopback must be rejected');
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_LOOPBACK_REJECTED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: rejects IPv4-mapped IPv6 RFC-1918 [::ffff:192.168.0.5] (S21 bypass)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:192.168.0.5]/v1/metrics', { env: {} });
|
||||
assert.equal(r.valid, false, 'IPv4-mapped RFC-1918 must be rejected');
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_RFC1918_REJECTED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: IPv4-mapped metadata [::ffff:169.254.169.254] HARD_BLOCKED even with opt-in (S21 bypass)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:169.254.169.254]/latest/meta-data',
|
||||
{ env: { VOYAGE_OTEL_ALLOW_PRIVATE: '1' } });
|
||||
assert.equal(r.valid, false, 'IPv4-mapped cloud-metadata MUST stay hard-blocked');
|
||||
assert.ok(r.errors.find(e => e.code === 'ENDPOINT_HARD_BLOCKED'));
|
||||
});
|
||||
|
||||
test('endpoint-validator: accepts IPv4-mapped IPv6 public [::ffff:8.8.8.8] over https (no over-block)', () => {
|
||||
const r = validateOtlpEndpoint('https://[::ffff:8.8.8.8]/v1/metrics', { env: {} });
|
||||
assert.equal(r.valid, true, JSON.stringify(r.errors));
|
||||
assert.equal(r.parsed.isPrivate, false);
|
||||
});
|
||||
|
||||
test('endpoint-validator: rejects empty / non-string', () => {
|
||||
assert.equal(validateOtlpEndpoint('').valid, false);
|
||||
assert.equal(validateOtlpEndpoint(null).valid, false);
|
||||
|
|
|
|||
|
|
@ -905,6 +905,18 @@ test('S17: NW2 bake-off verdict is not labeled unqualified POSITIVE (raw data un
|
|||
);
|
||||
});
|
||||
|
||||
test('S21: T2-bakeoff §3 marks the auto/bypass classifier check as an OPEN RESIDUAL, not satisfied (Survivor #18)', () => {
|
||||
// The W1 charter made an auto/bypass-mode re-run an explicit guard (trekreview
|
||||
// runs headless under those modes). It was never performed and was footnoted
|
||||
// rather than gating. Per recommendation #9, the classifier-interference
|
||||
// result must read as an open residual / untested — not "metric satisfied".
|
||||
const doc = read('docs/T2-bakeoff-results.md');
|
||||
assert.ok(
|
||||
/open residual/i.test(doc) && /untested/i.test(doc),
|
||||
'T2-bakeoff §3 must mark the auto/bypass classifier check as an OPEN RESIDUAL / untested (Survivor #18 / S21).',
|
||||
);
|
||||
});
|
||||
|
||||
// ── S18: framing-hardening cross-file invariants ───────────────────────────
|
||||
|
||||
test('S18: --min-brief-version is documented at the trekplan + trekresearch boundary', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue