From ad4101bb4963f1f6373b1b4c5bb4e56cc8bbd6d1 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 15:44:34 -0400 Subject: [PATCH] fix(relay-bench): judge 6to4 and NAT64-local by the embedded v4, refuse Teredo, scrub the pair line and sibling scripts --- .../tools/relay-bench/region-probe-replay.mjs | 5 +-- .../relay-bench/relay-bench-invocation.mjs | 35 ++++++++++++------- .../relay-bench-invocation.test.mjs | 9 ++++- tests/tools/relay-bench/relay-hop-latency.mjs | 7 ++-- .../relay-bench/relay-phone-connect-bench.mjs | 6 ++-- 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/tests/tools/relay-bench/region-probe-replay.mjs b/tests/tools/relay-bench/region-probe-replay.mjs index 9f97b13deef..180df6e80c5 100644 --- a/tests/tools/relay-bench/region-probe-replay.mjs +++ b/tests/tools/relay-bench/region-probe-replay.mjs @@ -2,8 +2,9 @@ // sample count, spread rule, and Node fetch, and prints why each region passed or failed. import { pathToFileURL } from 'node:url' import { - classifyPublicHttpsOrigin, LIVE_ENV_VAR, + classifyPublicHttpsOrigin, + describeUntrustedText, parseArgs, requireBoundedInteger, requireDirector, @@ -112,7 +113,7 @@ async function main() { console.error( timedOut ? `director ${director}/v1/regions did not answer within ${CATALOG_TIMEOUT_MS} ms` - : `director ${director}/v1/regions failed: ${err.message}` + : `director ${director}/v1/regions failed: ${describeUntrustedText(err.code ?? err.message)}` ) process.exitCode = 1 return diff --git a/tests/tools/relay-bench/relay-bench-invocation.mjs b/tests/tools/relay-bench/relay-bench-invocation.mjs index 1db1ed38dc8..38abbf142aa 100644 --- a/tests/tools/relay-bench/relay-bench-invocation.mjs +++ b/tests/tools/relay-bench/relay-bench-invocation.mjs @@ -190,28 +190,39 @@ function ipv6ToBytes(host) { return bytes } +function v4At(bytes, offset) { + return ( + ((bytes[offset] << 24) >>> 0) + + (bytes[offset + 1] << 16) + + (bytes[offset + 2] << 8) + + bytes[offset + 3] + ) +} + function isPublicIpv6(bytes) { const leadingZeros = bytes.slice(0, 10).every((byte) => byte === 0) if (leadingZeros && bytes[10] === 0xff && bytes[11] === 0xff) { - return isPublicIpv4( - ((bytes[12] << 24) >>> 0) + (bytes[13] << 16) + (bytes[14] << 8) + bytes[15] - ) + return isPublicIpv4(v4At(bytes, 12)) } if (leadingZeros && bytes[10] === 0 && bytes[11] === 0) { // Covers :: and ::1 as well as the deprecated v4-compatible form. return false } - // NAT64 (64:ff9b::/96) reaches the embedded v4 address, so judge that address. + // Transition prefixes that reach an embedded v4 address: judge that address. + // NAT64 64:ff9b::/96 and 64:ff9b:1::/48 (v4 in the last 32 bits), 6to4 2002::/16 + // (v4 in bits 16-47). Teredo 2001:0::/32 embeds the client v4 inverted, so it is refused. + const nat64 = bytes[0] === 0x00 && bytes[1] === 0x64 && bytes[2] === 0xff && bytes[3] === 0x9b if ( - bytes[0] === 0x00 && - bytes[1] === 0x64 && - bytes[2] === 0xff && - bytes[3] === 0x9b && - bytes.slice(4, 12).every((byte) => byte === 0) + nat64 && + (bytes.slice(4, 12).every((byte) => byte === 0) || (bytes[4] === 0 && bytes[5] === 1)) ) { - return isPublicIpv4( - ((bytes[12] << 24) >>> 0) + (bytes[13] << 16) + (bytes[14] << 8) + bytes[15] - ) + return isPublicIpv4(v4At(bytes, 12)) + } + if (bytes[0] === 0x20 && bytes[1] === 0x02) { + return isPublicIpv4(v4At(bytes, 2)) + } + if (bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0 && bytes[3] === 0) { + return false } if ((bytes[0] & 0xfe) === 0xfc || bytes[0] === 0xff) { return false diff --git a/tests/tools/relay-bench/relay-bench-invocation.test.mjs b/tests/tools/relay-bench/relay-bench-invocation.test.mjs index ec8f3951f0e..4ba0cfff8cc 100644 --- a/tests/tools/relay-bench/relay-bench-invocation.test.mjs +++ b/tests/tools/relay-bench/relay-bench-invocation.test.mjs @@ -168,6 +168,10 @@ describe('classifyPublicHttpsOrigin', () => { ['not a url', 'not a URL'], ['https://[64:ff9b::7f00:1]/', 'loopback, link-local, or private'], ['https://[64:ff9b::a9fe:a9fe]/', 'loopback, link-local, or private'], + ['https://[64:ff9b:1::7f00:1]/', 'loopback, link-local, or private'], + ['https://[2002:7f00:1::]/', 'loopback, link-local, or private'], + ['https://[2002:c0a8:101::1]/', 'loopback, link-local, or private'], + ['https://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/', 'loopback, link-local, or private'], ['', 'missing origin'] ])('refuses %s', (value, reason) => { const verdict = classifyPublicHttpsOrigin(value) @@ -175,8 +179,11 @@ describe('classifyPublicHttpsOrigin', () => { expect(verdict.reason).toContain(reason) }) - it('accepts a NAT64 address that embeds a public v4 address', () => { + it('accepts transition addresses that embed a public v4 address', () => { expect(classifyPublicHttpsOrigin('https://[64:ff9b::808:808]/').ok).toBe(true) + expect(classifyPublicHttpsOrigin('https://[2002:808:808::]/').ok).toBe(true) + // A 2001: address outside the Teredo /32 is ordinary global unicast. + expect(classifyPublicHttpsOrigin('https://[2001:db8::1]/').ok).toBe(true) }) it('never echoes control bytes from a refused value, since the desktop chose it', () => { diff --git a/tests/tools/relay-bench/relay-hop-latency.mjs b/tests/tools/relay-bench/relay-hop-latency.mjs index 66ebc8b8bd1..d0a076b7b7d 100644 --- a/tests/tools/relay-bench/relay-hop-latency.mjs +++ b/tests/tools/relay-bench/relay-hop-latency.mjs @@ -6,6 +6,7 @@ import { performance } from 'node:perf_hooks' import { pathToFileURL } from 'node:url' import { LIVE_ENV_VAR, + describeUntrustedText, parseArgs, requireBoundedInteger, requireDirector, @@ -46,7 +47,9 @@ async function timeResolve(director, relayHostId) { return { ms: Math.round(performance.now() - started), status: null, - error: timedOut ? `timeout after ${RESOLVE_TIMEOUT_MS} ms` : err.message + error: timedOut + ? `timeout after ${RESOLVE_TIMEOUT_MS} ms` + : describeUntrustedText(err.code ?? err.message) } } } @@ -91,7 +94,7 @@ function timeCellHello(cell, relayHostId) { }) ws.on('message', (message) => done({ hello: message.toString().slice(0, 80) })) ws.on('close', (code, reason) => done({ close: code, reason: reason.toString() })) - ws.on('error', (err) => done({ error: err.message })) + ws.on('error', (err) => done({ error: describeUntrustedText(err.code ?? err.message) })) }) } diff --git a/tests/tools/relay-bench/relay-phone-connect-bench.mjs b/tests/tools/relay-bench/relay-phone-connect-bench.mjs index 301c7a106f2..80c70d924f0 100644 --- a/tests/tools/relay-bench/relay-phone-connect-bench.mjs +++ b/tests/tools/relay-bench/relay-phone-connect-bench.mjs @@ -170,7 +170,7 @@ export function dialRelay({ const msg = JSON.parse(text) if (msg.type !== 'e2ee_authenticated') { // Type only: the plaintext is the desktop's and would land in row.error. - throw new Error(`auth rejected: desktop sent ${JSON.stringify(msg.type ?? null)}`) + throw new Error(`auth rejected: desktop sent ${describeUntrustedText(msg.type)}`) } mark('e2eeAuthenticated') stage = 'ready' @@ -401,7 +401,9 @@ async function pair(pairingUrl, statePath) { const resumeToken = b64url(nacl.randomBytes(32)) const resumeTokenHash = b64url(sha256(utf8(resumeToken))) const installReqId = `install-${b64url(nacl.randomBytes(12))}` - console.log(`pair: dialing ${relay.cellUrl} host=${relay.relayHostId}`) + // The offer is operator-pasted text: the vetted origin, not the raw URL, and the host id + // through the same scrub as any other peer-chosen string. + console.log(`pair: dialing ${verdict.origin} host=${describeUntrustedText(relay.relayHostId)}`) const dial = await dialRelay({ cellUrl: relay.cellUrl, relayHostId: relay.relayHostId,