diff --git a/scripts/conformance/crates-registry-source.mjs b/scripts/conformance/crates-registry-source.mjs new file mode 100644 index 000000000..48587177e --- /dev/null +++ b/scripts/conformance/crates-registry-source.mjs @@ -0,0 +1,13 @@ +const OFFICIAL_CRATES_IO_REGISTRY_SOURCES = new Set([ + 'registry+https://github.com/rust-lang/crates.io-index', + 'registry+https://index.crates.io', + 'registry+https://index.crates.io/', + 'registry+sparse+https://index.crates.io', + 'registry+sparse+https://index.crates.io/', + 'sparse+https://index.crates.io', + 'sparse+https://index.crates.io/', +]); + +export function isOfficialCratesIoRegistrySource(source) { + return typeof source === 'string' && OFFICIAL_CRATES_IO_REGISTRY_SOURCES.has(source); +} diff --git a/scripts/conformance/heartbeat-shared-relay.mjs b/scripts/conformance/heartbeat-shared-relay.mjs index eb21c92e6..423d4747e 100644 --- a/scripts/conformance/heartbeat-shared-relay.mjs +++ b/scripts/conformance/heartbeat-shared-relay.mjs @@ -3,6 +3,8 @@ import http from 'node:http'; import path from 'node:path'; import process from 'node:process'; import { pathToFileURL } from 'node:url'; +import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib'; +import { proxyHttpRequest, proxyTargetUrl } from './proxy-target.mjs'; const MAXIMUM_REQUEST_BYTES = 1024 * 1024; const MAXIMUM_RESPONSE_BYTES = 16 * 1024 * 1024; @@ -252,7 +254,7 @@ async function requestBody(request) { async function responseBody(response) { const chunks = []; let bytes = 0; - for await (const chunk of response.body ?? []) { + for await (const chunk of response) { const buffer = Buffer.from(chunk); bytes += buffer.length; if (bytes > MAXIMUM_RESPONSE_BYTES) { @@ -260,36 +262,65 @@ async function responseBody(response) { } chunks.push(buffer); } - return Buffer.concat(chunks); + const encoded = Buffer.concat(chunks); + const encoding = String(response.headers['content-encoding'] ?? 'identity').toLowerCase(); + const options = { maxOutputLength: MAXIMUM_RESPONSE_BYTES }; + const decoders = { + identity: (value) => value, + gzip: (value) => gunzipSync(value, options), + deflate: (value) => inflateSync(value, options), + br: (value) => brotliDecompressSync(value, options), + }; + if (!(encoding in decoders)) throw new Error(`unsupported relay response encoding: ${encoding}`); + const decoded = decoders[encoding](encoded); + if (decoded.length > MAXIMUM_RESPONSE_BYTES) { + throw new Error(`relay response exceeded ${MAXIMUM_RESPONSE_BYTES} bytes`); + } + return decoded; +} + +async function directHttpRequest({ requestTarget, allowedOrigin, method, headers, body }) { + const response = await new Promise((resolve, reject) => { + const request = proxyHttpRequest(requestTarget, allowedOrigin, { method, headers }, resolve); + request.setTimeout(REQUEST_TIMEOUT_MS, () => { + request.destroy(new Error(`relay request timed out after ${REQUEST_TIMEOUT_MS}ms`)); + }); + request.once('error', reject); + request.end(body.length > 0 || !['GET', 'HEAD'].includes(method) ? body : undefined); + }); + return { + status: response.statusCode ?? 502, + contentType: response.headers['content-type'] ?? '', + body: await responseBody(response), + }; } export async function directRelayRequest({ targetUrl, + allowedOrigin, method, headers, body, - fetchImpl = globalThis.fetch, + requestImpl = directHttpRequest, }) { - if (typeof fetchImpl !== 'function') throw new Error('fetchImpl must be a function'); - const forwardedHeaders = new Headers(); + if (typeof requestImpl !== 'function') throw new Error('requestImpl must be a function'); + const target = new URL(targetUrl); + if (target.origin !== new URL(allowedOrigin).origin) { + throw new Error('relay request changed the configured upstream origin'); + } + const forwardedHeaders = {}; for (const [name, value] of Object.entries(headers)) { if (EXCLUDED_HEADERS.has(name) || value === undefined) continue; - const values = Array.isArray(value) ? value : [value]; - for (const entry of values) forwardedHeaders.append(name, String(entry)); + forwardedHeaders[name] = Array.isArray(value) ? value.map(String) : String(value); } - const response = await fetchImpl(targetUrl, { + return requestImpl({ + requestTarget: `${target.pathname}${target.search}`, + allowedOrigin, method, headers: forwardedHeaders, - body: body.length > 0 || !['GET', 'HEAD'].includes(method) ? body : undefined, - redirect: 'manual', - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + body, }); - return { - status: response.status, - contentType: response.headers.get('content-type') ?? '', - body: await responseBody(response), - }; } export function createHeartbeatRelayServer({ @@ -305,10 +336,10 @@ export function createHeartbeatRelayServer({ return http.createServer(async (request, response) => { try { const body = await requestBody(request); - const requestedUrl = new URL(request.url ?? '/', 'http://heartbeat-relay.invalid'); - const targetUrl = new URL(`${requestedUrl.pathname}${requestedUrl.search}`, target); + const targetUrl = proxyTargetUrl(request.url, target); const forwarded = await executeRequest({ targetUrl: targetUrl.toString(), + allowedOrigin: target.origin, method: request.method ?? 'GET', headers: request.headers, body, diff --git a/scripts/conformance/proxy-target.mjs b/scripts/conformance/proxy-target.mjs new file mode 100644 index 000000000..9087ce46b --- /dev/null +++ b/scripts/conformance/proxy-target.mjs @@ -0,0 +1,53 @@ +import http from 'node:http'; +import https from 'node:https'; + +const REQUEST_TARGET_BASE = 'http://request-target.invalid'; +const DESTINATION_OPTIONS = ['protocol', 'host', 'hostname', 'port', 'socketPath', 'href', 'origin', 'path']; + +function proxyUpstream(configuredOrigin) { + const upstream = configuredOrigin instanceof URL + ? configuredOrigin + : new URL(configuredOrigin); + if (!['http:', 'https:'].includes(upstream.protocol) + || upstream.username + || upstream.password + || upstream.pathname !== '/' + || upstream.search + || upstream.hash) { + throw new Error('proxy upstream must be an HTTP(S) origin without credentials'); + } + + return upstream; +} + +export function proxyTargetPath(requestTarget) { + const incoming = new URL(requestTarget ?? '/', REQUEST_TARGET_BASE); + return { + path: `${incoming.pathname}${incoming.search}`, + pathname: incoming.pathname, + }; +} + +export function proxyTargetUrl(requestTarget, configuredOrigin) { + const upstream = proxyUpstream(configuredOrigin); + const target = new URL(proxyTargetPath(requestTarget).path, upstream); + if (target.origin !== upstream.origin) { + throw new Error('proxy request target changed the configured upstream origin'); + } + + return target; +} + +export function proxyHttpRequest(requestTarget, configuredOrigin, options = {}, callback) { + const upstream = proxyUpstream(configuredOrigin); + for (const name of DESTINATION_OPTIONS) { + if (Object.hasOwn(options, name)) { + throw new Error(`proxy request options must not override ${name}`); + } + } + const transport = upstream.protocol === 'https:' ? https : http; + return transport.request(upstream.origin, { + ...options, + path: proxyTargetPath(requestTarget).path, + }, callback); +} diff --git a/scripts/conformance/server-parity/published-sdk-patch/observe-http.mjs b/scripts/conformance/server-parity/published-sdk-patch/observe-http.mjs index 19c2b0671..1e6d81984 100644 --- a/scripts/conformance/server-parity/published-sdk-patch/observe-http.mjs +++ b/scripts/conformance/server-parity/published-sdk-patch/observe-http.mjs @@ -2,6 +2,7 @@ import http from 'node:http'; import {appendFileSync} from 'node:fs'; import {gunzipSync, inflateSync, brotliDecompressSync} from 'node:zlib'; +import {proxyHttpRequest, proxyTargetPath} from '../../proxy-target.mjs'; const [target, journal, observationMode, gateMode, gateWorkflowId, ...extra] = process.argv.slice(2); if (observationMode !== undefined && observationMode !== '--forwarding-state') throw new Error('Unknown observation mode'); @@ -29,7 +30,7 @@ const server = http.createServer((request, response) => { const record = fields => { if (recorded) return; recorded = true; - appendFileSync(journal, JSON.stringify({method: request.method, path: new URL(request.url, upstream).pathname, + appendFileSync(journal, JSON.stringify({method: request.method, path: proxyTargetPath(request.url).pathname, ...(captureNamespace ? {namespace: request.headers['x-namespace'] ?? null} : {}), request_body: Buffer.concat(chunks).toString('utf8'), status: 0, response_encoding: 'identity', response_retry_after: null, response_bytes_base64: '', response_body: '', client_cancelled: false, @@ -58,13 +59,13 @@ const server = http.createServer((request, response) => { if (recorded) return; const arrivalLine = arrivalJournal ? JSON.stringify({schema: 'durable-workflow.server-parity-http-request-arrival/v1', request_observation_id: requestObservationId, method: request.method, - path: new URL(request.url, upstream).pathname, request_body: body.toString('utf8'), + path: proxyTargetPath(request.url).pathname, request_body: body.toString('utf8'), request_body_complete: true, upstream_request_started: true, response_observed: false})+'\n' : null; const arrivalSize = arrivalLine === null ? 0 : Buffer.byteLength(arrivalLine); if (arrivalLine && (arrivalCount >= 512 || arrivalBytes + arrivalSize > 4 * 1024 * 1024)) { throw new Error('Request-arrival journal exceeds its bounded fixture budget'); } - forwarded = http.request(new URL(request.url, upstream), { + forwarded = proxyHttpRequest(request.url, upstream, { method: request.method, headers: {...request.headers, host: upstream.host}, }, incoming => { @@ -82,7 +83,7 @@ const server = http.createServer((request, response) => { response_bytes_base64: bytes.toString('base64'), response_body: decoded}); response.writeHead(incoming.statusCode, incoming.headers); response.end(bytes); - if (!startObserved && request.method === 'POST' && new URL(request.url, upstream).pathname === '/api/workflows' + if (!startObserved && request.method === 'POST' && proxyTargetPath(request.url).pathname === '/api/workflows' && incoming.statusCode === 201) { let started, receipt; try { started = JSON.parse(body.toString('utf8')); receipt = JSON.parse(decoded); } catch { return; } @@ -107,7 +108,7 @@ const server = http.createServer((request, response) => { // Keep the real worker request intact until this fixture's accepted start // is fully observed. Other cohorts retain their original forwarding behavior. if (!startObserved && request.method === 'POST' - && new URL(request.url, upstream).pathname === '/api/worker/workflow-tasks/poll') waitingPolls.add(forwardRequest); + && proxyTargetPath(request.url).pathname === '/api/worker/workflow-tasks/poll') waitingPolls.add(forwardRequest); else forwardRequest(); }); }); diff --git a/scripts/conformance/server-parity/query-authority/observer.mjs b/scripts/conformance/server-parity/query-authority/observer.mjs index 5ede5a3ac..6498a554a 100644 --- a/scripts/conformance/server-parity/query-authority/observer.mjs +++ b/scripts/conformance/server-parity/query-authority/observer.mjs @@ -4,6 +4,7 @@ import http from 'node:http'; import {appendFileSync} from 'node:fs'; import {createHash} from 'node:crypto'; import {gunzipSync, inflateSync, brotliDecompressSync} from 'node:zlib'; +import {proxyHttpRequest, proxyTargetPath} from '../../proxy-target.mjs'; const [target, mode, journal] = process.argv.slice(2); if (!['timely', 'late-poll', 'late-handler-async', 'late-handler-sync'].includes(mode)) throw Error('Unknown case'); @@ -34,7 +35,7 @@ function exchange(method, path, headers, body = Buffer.alloc(0)) { }; reject(error); }; - const request = http.request(new URL(path, upstream), {method, headers: {...headers, host: upstream.host}}, incoming => { + const request = proxyHttpRequest(path, upstream, {method, headers: {...headers, host: upstream.host}}, incoming => { response = incoming; const chunks = []; incoming.on('data', chunk => { @@ -66,7 +67,7 @@ function json(reply) { } const sha = bytes => createHash('sha256').update(bytes).digest('hex'); const server = http.createServer(async (request, response) => { - const path = new URL(request.url, upstream).pathname; + const path = proxyTargetPath(request.url).pathname; let upstreamStarted = false; const forward = (...arguments_) => { upstreamStarted = true; diff --git a/scripts/conformance/skew-published-artifacts.mjs b/scripts/conformance/skew-published-artifacts.mjs index 2896e6664..64d1e8394 100755 --- a/scripts/conformance/skew-published-artifacts.mjs +++ b/scripts/conformance/skew-published-artifacts.mjs @@ -5,6 +5,8 @@ import path from 'node:path'; import process from 'node:process'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { brotliDecompressSync, gunzipSync, inflateSync } from 'node:zlib'; +import { proxyHttpRequest, proxyTargetUrl } from './proxy-target.mjs'; import { isExactPythonRelease, isExactSemverRelease } from './version-identities.mjs'; const RESULT_SCHEMA = 'durable-workflow.v2.skew-refusal-matrix.result'; @@ -3356,6 +3358,7 @@ function normalizeArtifactHeaders(headers) { async function withRecordingProxy({ targetUrl, artifactProxyHost = null }, callback) { const captures = []; + const targetOrigin = new URL(targetUrl); const server = http.createServer(async (request, response) => { const chunks = []; request.on('data', (chunk) => chunks.push(chunk)); @@ -3366,7 +3369,7 @@ async function withRecordingProxy({ targetUrl, artifactProxyHost = null }, callb delete headers.connection; delete headers['content-length']; - const requestUrl = new URL(request.url ?? '/', targetUrl); + const requestUrl = proxyTargetUrl(request.url, targetOrigin); const capture = { id: `proxy-${captures.length + 1}`, request: { @@ -3383,20 +3386,29 @@ async function withRecordingProxy({ targetUrl, artifactProxyHost = null }, callb }; try { - const upstream = await fetch(requestUrl, { - method: request.method, - headers, - body: requestBody.length > 0 ? requestBody : undefined, + const upstream = await new Promise((resolve, reject) => { + const forwarded = proxyHttpRequest(request.url, targetOrigin, { + method: request.method, + headers, + }, resolve); + forwarded.once('error', reject); + forwarded.end(requestBody.length > 0 ? requestBody : undefined); }); - const text = await upstream.text(); + const responseChunks = []; + for await (const chunk of upstream) responseChunks.push(Buffer.from(chunk)); + const encoded = Buffer.concat(responseChunks); + const encoding = String(upstream.headers['content-encoding'] ?? 'identity').toLowerCase(); + const decoders = { identity: (value) => value, gzip: gunzipSync, deflate: inflateSync, br: brotliDecompressSync }; + if (!(encoding in decoders)) throw new Error(`Unsupported proxy response encoding: ${encoding}`); + const text = decoders[encoding](encoded).toString('utf8'); const parsed = parseJson(text); - const responseHeaders = Object.fromEntries(upstream.headers.entries()); + const responseHeaders = normalizeArtifactHeaders(upstream.headers); capture.response = { - status: upstream.status, + status: upstream.statusCode ?? 502, headers: responseHeaders, body: parsed ?? text, }; - response.statusCode = upstream.status; + response.statusCode = upstream.statusCode ?? 502; for (const [key, value] of Object.entries(responseHeaders)) { if (['content-encoding', 'content-length', 'transfer-encoding', 'connection'].includes(key.toLowerCase())) { continue; diff --git a/scripts/conformance/workflow-lifecycle-published-artifacts.mjs b/scripts/conformance/workflow-lifecycle-published-artifacts.mjs index f3e754bd7..cd335ab89 100644 --- a/scripts/conformance/workflow-lifecycle-published-artifacts.mjs +++ b/scripts/conformance/workflow-lifecycle-published-artifacts.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { isOfficialCratesIoRegistrySource } from './crates-registry-source.mjs'; import { isExactPythonRelease, isExactSemverRelease } from './version-identities.mjs'; const RESULT_DIR = mustEnv('RESULT_DIR'); @@ -1169,7 +1170,7 @@ function rustArtifactMismatch(outputs) { || stringValue(provenance.package) !== 'durable-workflow' || stringValue(provenance.requested_version) !== sdkVersion || stringValue(provenance.installed_version) !== sdkVersion - || !stringValue(provenance.registry_source).includes('crates.io') + || !isOfficialCratesIoRegistrySource(stringValue(provenance.registry_source)) || !/^[0-9a-f]{64}$/.test(stringValue(provenance.registry_checksum_sha256)); } @@ -2283,7 +2284,7 @@ function validateRustSdkLifecycleSurface(outputs) { if (provenance.package !== 'durable-workflow' || provenance.requested_version !== outputs.artifact_version || provenance.installed_version !== outputs.artifact_version - || !normalizedText(provenance.registry_source).includes('crates.io') + || !isOfficialCratesIoRegistrySource(normalizedText(provenance.registry_source)) || !/^[0-9a-f]{64}$/.test(stringValue(provenance.registry_checksum_sha256))) { failures.push('install_provenance must prove the exact crates.io durable-workflow package and checksum'); } @@ -2292,7 +2293,7 @@ function validateRustSdkLifecycleSurface(outputs) { || payload.envelope_contract !== 'durable-workflow-published-envelope' || payload.apache_avro_package !== 'apache-avro' || !truthyFlag(payload.official_crates_io_provenance) - || !normalizedText(payload.apache_avro_registry_source).includes('crates.io') + || !isOfficialCratesIoRegistrySource(normalizedText(payload.apache_avro_registry_source)) || !/^[0-9a-f]{64}$/.test(stringValue(payload.apache_avro_registry_checksum_sha256))) { failures.push('payload_contract must prove the official apache-avro crate and published Avro envelope'); } @@ -2434,7 +2435,7 @@ function normalizeSuppliedFindings(entry, scenario, status, classification, fall function generatedFinding(scenario, status, classification, summary) { return { - finding_id: `workflow-lifecycle-${scenario.id.replace(/_/g, '-')}-${classification.replace(/-/g, '-')}`, + finding_id: `workflow-lifecycle-${scenario.id.replace(/_/g, '-')}-${classification}`, finding_type: findingTypeFor(classification, status), classification, scenario_id: scenario.id, @@ -2614,7 +2615,7 @@ function normalizeScenario(scenario, entry, policy) { function findingForPolicy(index, classification, summary) { return { - finding_id: `workflow-lifecycle-${classification.replace(/-/g, '-')}-${index + 1}`, + finding_id: `workflow-lifecycle-${classification}-${index + 1}`, finding_type: findingTypeFor(classification, classification === 'runner-gap' ? 'runner_blocked' : 'fail'), classification, scenario_id: 'artifact_policy', diff --git a/scripts/conformance/workflow-lifecycle-rust-published-artifacts.mjs b/scripts/conformance/workflow-lifecycle-rust-published-artifacts.mjs index 2dbc42cc2..99e19fb10 100644 --- a/scripts/conformance/workflow-lifecycle-rust-published-artifacts.mjs +++ b/scripts/conformance/workflow-lifecycle-rust-published-artifacts.mjs @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; +import { isOfficialCratesIoRegistrySource } from './crates-registry-source.mjs'; import { isExactSemverRelease } from './version-identities.mjs'; const RESULT_DIR = required('RESULT_DIR'); @@ -148,7 +149,7 @@ function provenance(lock, name, version = '') { const resolvedVersion = block.match(/version = "([^"]+)"/)?.[1] || ''; const source = block.match(/source = "([^"]+)"/)?.[1] || ''; const checksum = block.match(/checksum = "([0-9a-f]{64})"/)?.[1] || ''; - if (!source.includes('crates.io') || !checksum) { + if (!isOfficialCratesIoRegistrySource(source) || !checksum) { throw new Error(`${name} did not resolve from crates.io with a registry checksum`); } return { package: name, resolved_version: resolvedVersion, registry_source: source, registry_checksum_sha256: checksum }; diff --git a/tests/Support/ServerParityDeadlinePressureProxy.mjs b/tests/Support/ServerParityDeadlinePressureProxy.mjs index df1b3fe85..9dc857c7d 100644 --- a/tests/Support/ServerParityDeadlinePressureProxy.mjs +++ b/tests/Support/ServerParityDeadlinePressureProxy.mjs @@ -5,6 +5,7 @@ import {spawn} from 'node:child_process'; import {once} from 'node:events'; import {writeFileSync} from 'node:fs'; import assert from 'node:assert/strict'; +import {proxyHttpRequest} from '../../scripts/conformance/proxy-target.mjs'; const [target,output,prefix,artifacts]=process.argv.slice(2); const upstream=new URL(target); @@ -26,7 +27,7 @@ const server=http.createServer((request,response)=>{ return; } } - const forward=http.request(new URL(request.url,upstream),{method:request.method, + const forward=proxyHttpRequest(request.url,upstream,{method:request.method, headers:{...request.headers,host:upstream.host}},incoming=>{ response.writeHead(incoming.statusCode,incoming.headers);incoming.pipe(response); }); diff --git a/tests/Unit/ConformanceProxyTargetTest.mjs b/tests/Unit/ConformanceProxyTargetTest.mjs new file mode 100644 index 000000000..9985b7855 --- /dev/null +++ b/tests/Unit/ConformanceProxyTargetTest.mjs @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { once } from 'node:events'; +import test from 'node:test'; + +import { proxyHttpRequest, proxyTargetPath, proxyTargetUrl } from '../../scripts/conformance/proxy-target.mjs'; + +test('proxy target retains the configured origin and requested path', () => { + assert.equal( + proxyTargetUrl('/api/workflows?status=running', 'http://server:8080').toString(), + 'http://server:8080/api/workflows?status=running', + ); +}); + +test('absolute and network-path request targets cannot replace the configured origin', () => { + assert.equal( + proxyTargetUrl('http://169.254.169.254/latest/meta-data', 'http://server:8080').toString(), + 'http://server:8080/latest/meta-data', + ); + assert.equal( + proxyTargetUrl('//attacker.invalid/collect?secret=1', 'http://server:8080').toString(), + 'http://server:8080/collect?secret=1', + ); +}); + +test('proxy upstream is an exact HTTP origin without embedded credentials', () => { + for (const target of [ + 'file:///tmp/socket', + 'http://user:secret@server:8080', + 'http://server:8080/prefix', + 'http://server:8080/?route=other', + 'http://server:8080/#fragment', + ]) { + assert.throws(() => proxyTargetUrl('/api/ready', target), /HTTP\(S\) origin/); + } +}); + +test('proxy request uses only the configured origin for the network destination', async (context) => { + let configuredRequests = 0; + let configuredRequestTarget = null; + let attackerRequests = 0; + const configured = http.createServer((request, response) => { + configuredRequests++; + configuredRequestTarget = request.url; + response.end('configured origin reached'); + }); + const attacker = http.createServer((_request, response) => { + attackerRequests++; + response.end('unexpected'); + }); + context.after(async () => { + configured.closeAllConnections(); + attacker.closeAllConnections(); + await Promise.all([ + new Promise((resolve) => configured.close(resolve)), + new Promise((resolve) => attacker.close(resolve)), + ]); + }); + configured.listen(0, '127.0.0.1'); + attacker.listen(0, '127.0.0.1'); + await Promise.all([once(configured, 'listening'), once(attacker, 'listening')]); + const configuredAddress = configured.address(); + const attackerAddress = attacker.address(); + assert.equal(typeof configuredAddress, 'object'); + assert.equal(typeof attackerAddress, 'object'); + + const response = await new Promise((resolve, reject) => { + const request = proxyHttpRequest( + `http://127.0.0.1:${attackerAddress.port}/latest/meta-data?role=admin`, + `http://127.0.0.1:${configuredAddress.port}`, + {}, + resolve, + ); + request.once('error', reject); + request.end(); + }); + const chunks = []; + for await (const chunk of response) chunks.push(chunk); + assert.equal(Buffer.concat(chunks).toString(), 'configured origin reached'); + assert.equal(configuredRequests, 1); + assert.equal(configuredRequestTarget, '/latest/meta-data?role=admin'); + assert.equal(attackerRequests, 0); +}); + +test('proxy request options cannot replace the configured destination or path', () => { + for (const options of [ + { hostname: 'attacker.invalid' }, + { port: 1 }, + { socketPath: '/tmp/attacker.sock' }, + { path: '//attacker.invalid/collect' }, + ]) { + assert.throws( + () => proxyHttpRequest('/api/ready', 'http://server:8080', options), + /must not override/, + ); + } + assert.deepEqual(proxyTargetPath('//attacker.invalid/a?b=1'), { + path: '/a?b=1', + pathname: '/a', + }); +}); diff --git a/tests/Unit/CratesRegistrySourceTest.mjs b/tests/Unit/CratesRegistrySourceTest.mjs new file mode 100644 index 000000000..245c9b0d5 --- /dev/null +++ b/tests/Unit/CratesRegistrySourceTest.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { isOfficialCratesIoRegistrySource } from '../../scripts/conformance/crates-registry-source.mjs'; + +test('official crates.io Cargo registry spellings are accepted', () => { + for (const source of [ + 'registry+https://github.com/rust-lang/crates.io-index', + 'registry+https://index.crates.io', + 'registry+https://index.crates.io/', + 'registry+sparse+https://index.crates.io', + 'registry+sparse+https://index.crates.io/', + 'sparse+https://index.crates.io', + 'sparse+https://index.crates.io/', + ]) { + assert.equal(isOfficialCratesIoRegistrySource(source), true, source); + } +}); + +test('lookalike hosts and paths cannot satisfy crates.io provenance', () => { + for (const source of [ + 'registry+https://index.crates.io.attacker.invalid', + 'registry+https://attacker.invalid/crates.io', + 'registry+https://index.crates.io/alternate-index', + 'https://index.crates.io', + '', + ]) { + assert.equal(isOfficialCratesIoRegistrySource(source), false, source); + } +}); diff --git a/tests/Unit/HeartbeatConformanceRunnerContractTest.php b/tests/Unit/HeartbeatConformanceRunnerContractTest.php index 9b2c86242..b2ae15fbb 100644 --- a/tests/Unit/HeartbeatConformanceRunnerContractTest.php +++ b/tests/Unit/HeartbeatConformanceRunnerContractTest.php @@ -554,7 +554,7 @@ public function test_shared_wave_owns_one_clean_bootstrap_and_retains_independen $this->assertStringContainsString('export async function directRelayRequest(', $relay); $this->assertStringContainsString('function writeRelayPid(pidFile)', $relay); $this->assertStringContainsString('export function stopHeartbeatRelay(', $relay); - $this->assertStringContainsString('fetchImpl = globalThis.fetch', $relay); + $this->assertStringContainsString('requestImpl = directHttpRequest', $relay); $this->assertStringNotContainsString("spawn('docker'", $relay); $this->assertStringNotContainsString("'--header'", $relay); $this->assertStringContainsString( diff --git a/tests/Unit/HeartbeatSharedServerReadinessRegression.mjs b/tests/Unit/HeartbeatSharedServerReadinessRegression.mjs index 3737bdfe0..fcbbeaf71 100644 --- a/tests/Unit/HeartbeatSharedServerReadinessRegression.mjs +++ b/tests/Unit/HeartbeatSharedServerReadinessRegression.mjs @@ -2,11 +2,13 @@ import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; import fs from 'node:fs'; +import http from 'node:http'; import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { gzipSync } from 'node:zlib'; import { createHeartbeatRelayServer, @@ -281,6 +283,7 @@ test('executor-network relay keeps credentials and request bytes in memory', asy const requests = []; const result = await directRelayRequest({ targetUrl: 'http://server:8080/api/ready', + allowedOrigin: 'http://server:8080', method: 'POST', headers: { authorization: 'Bearer control-token', @@ -288,12 +291,13 @@ test('executor-network relay keeps credentials and request bytes in memory', asy 'x-namespace': 'hb-wave-php', }, body: Buffer.from('{"probe":true}'), - fetchImpl: async (url, options) => { - requests.push({ url, options }); - return new Response('{"ready":true}', { + requestImpl: async (request) => { + requests.push(request); + return { status: 200, - headers: { 'Content-Type': 'application/json' }, - }); + contentType: 'application/json', + body: Buffer.from('{"ready":true}'), + }; }, }); @@ -301,15 +305,79 @@ test('executor-network relay keeps credentials and request bytes in memory', asy assert.equal(result.contentType, 'application/json'); assert.equal(result.body.toString(), '{"ready":true}'); assert.equal(requests.length, 1); - assert.equal(requests[0].url, 'http://server:8080/api/ready'); - assert.equal(requests[0].options.method, 'POST'); - assert.equal( - requests[0].options.headers.get('authorization'), - 'Bearer control-token', + assert.equal(requests[0].requestTarget, '/api/ready'); + assert.equal(requests[0].allowedOrigin, 'http://server:8080'); + assert.equal(requests[0].method, 'POST'); + assert.equal(requests[0].headers.authorization, 'Bearer control-token'); + assert.equal(requests[0].headers['x-namespace'], 'hb-wave-php'); + assert.equal('connection' in requests[0].headers, false); + assert.equal(requests[0].body.toString(), '{"probe":true}'); +}); + +test('executor-network relay uses the pinned HTTP origin and decodes its bounded response', async (context) => { + const observed = []; + const upstream = http.createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + observed.push({ + url: request.url, + authorization: request.headers.authorization, + body: Buffer.concat(chunks).toString(), + }); + response.writeHead(200, { + 'content-encoding': 'gzip', + 'content-type': 'application/json', + }); + response.end(gzipSync('{"ready":true}')); + }); + context.after(async () => { + upstream.closeAllConnections(); + await new Promise((resolve) => upstream.close(resolve)); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + const address = upstream.address(); + assert.equal(typeof address, 'object'); + const origin = `http://127.0.0.1:${address.port}`; + + const result = await directRelayRequest({ + targetUrl: `${origin}/api/ready?source=relay`, + allowedOrigin: origin, + method: 'POST', + headers: { + authorization: 'Bearer control-token', + connection: 'keep-alive', + }, + body: Buffer.from('{"probe":true}'), + }); + + assert.deepEqual(observed, [{ + url: '/api/ready?source=relay', + authorization: 'Bearer control-token', + body: '{"probe":true}', + }]); + assert.equal(result.status, 200); + assert.equal(result.contentType, 'application/json'); + assert.equal(result.body.toString(), '{"ready":true}'); +}); + +test('executor-network relay refuses a request outside the configured origin', async () => { + let attempted = false; + await assert.rejects( + directRelayRequest({ + targetUrl: 'http://169.254.169.254/latest/meta-data', + allowedOrigin: 'http://server:8080', + method: 'GET', + headers: {}, + body: Buffer.alloc(0), + requestImpl: async () => { + attempted = true; + throw new Error('unexpected request'); + }, + }), + /changed the configured upstream origin/, ); - assert.equal(requests[0].options.headers.get('x-namespace'), 'hb-wave-php'); - assert.equal(requests[0].options.headers.has('connection'), false); - assert.equal(requests[0].options.body.toString(), '{"probe":true}'); + assert.equal(attempted, false); }); test('executor-network relay reports a bounded gateway failure', async (context) => {