diff --git a/src/lib/swarm/safe-federation-http.test.ts b/src/lib/swarm/safe-federation-http.test.ts new file mode 100644 index 0000000..c5016ea --- /dev/null +++ b/src/lib/swarm/safe-federation-http.test.ts @@ -0,0 +1,342 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + SafeFederationError, + createSafeFederationRequester, + isPublicFederationAddress, +} from './safe-federation-http'; + +interface TestServer { + readonly baseUrl: string; + close(): Promise; +} + +const openServers = new Set(); + +async function startServer( + handler: (request: IncomingMessage, response: ServerResponse) => void +): Promise { + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + + const address = server.address() as AddressInfo; + const testServer: TestServer = { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + }; + openServers.add(testServer); + return testServer; +} + +afterEach(async () => { + await Promise.all([...openServers].map((server) => server.close())); + openServers.clear(); +}); + +function expectCode(code: SafeFederationError['code']): (error: unknown) => boolean { + return (error) => error instanceof SafeFederationError && error.code === code; +} + +describe('E2EE federation address policy', () => { + it.each([ + '8.8.8.8', + '1.1.1.1', + '93.184.216.34', + '2001:4860:4860::8888', + '2606:4700:4700::1111', + ])('accepts a globally routable address: %s', (address) => { + expect(isPublicFederationAddress(address)).toBe(true); + }); + + it.each([ + '0.0.0.0', + '10.0.0.1', + '100.64.0.1', + '127.0.0.1', + '169.254.169.254', + '172.31.255.255', + '192.0.0.1', + '192.0.2.1', + '192.168.1.1', + '198.18.0.1', + '198.51.100.1', + '203.0.113.1', + '224.0.0.1', + '255.255.255.255', + '::', + '::1', + '::ffff:127.0.0.1', + '64:ff9b::7f00:1', + '100::1', + '2001:db8::1', + '2002:7f00:1::', + 'fc00::1', + 'fe80::1', + 'fec0::1', + 'ff02::1', + '4000::1', + 'not-an-ip', + ])('rejects a non-public or special-purpose address: %s', (address) => { + expect(isPublicFederationAddress(address)).toBe(false); + }); +}); + +describe('E2EE federation URL and DNS policy', () => { + it('allows HTTPS public ICANN hostnames and resolves exactly once', async () => { + const dnsResolver = vi.fn(async () => { + throw new Error('deliberate DNS stop'); + }); + const request = createSafeFederationRequester({ dnsResolver, development: false }); + + await expect(request('https://node.synapsis.social/api/e2ee/keys/alice')).rejects.toSatisfy( + expectCode('DNS_RESOLUTION_FAILED') + ); + expect(dnsResolver).toHaveBeenCalledOnce(); + expect(dnsResolver).toHaveBeenCalledWith('node.synapsis.social'); + }); + + it.each([ + 'http://node.synapsis.social/api/chat/receive', + 'http://localhost/api/chat/receive', + 'http://127.0.0.1/api/chat/receive', + 'http://[::1]/api/chat/receive', + 'https://8.8.8.8/api/chat/receive', + 'https://127.0.0.1/api/chat/receive', + 'https://[::1]/api/chat/receive', + 'https://localhost/api/chat/receive', + 'https://synapsis.test/api/chat/receive', + 'https://node.example/api/chat/receive', + 'https://example.com/api/chat/receive', + 'https://node.onion/api/chat/receive', + 'https://user:password@node.synapsis.social/api/chat/receive', + ])('rejects a non-public federation target before DNS: %s', async (url) => { + const dnsResolver = vi.fn(async () => [{ address: '8.8.8.8', family: 4 as const }]); + const request = createSafeFederationRequester({ dnsResolver, development: false }); + + await expect(request(url)).rejects.toSatisfy(expectCode('UNSAFE_URL')); + expect(dnsResolver).not.toHaveBeenCalled(); + }); + + it('rejects all resolved addresses when even one answer is unsafe', async () => { + const request = createSafeFederationRequester({ + development: false, + dnsResolver: async () => [ + { address: '8.8.8.8', family: 4 }, + { address: '10.0.0.7', family: 4 }, + ], + }); + + await expect(request('https://node.synapsis.social/api/chat/receive')).rejects.toSatisfy( + expectCode('UNSAFE_DNS_RESULT') + ); + }); + + it('uses one validated DNS answer set and pins the selected address for the socket', async () => { + const server = await startServer((_incoming, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"pinned":true}'); + }); + const dnsResolver = vi.fn(async () => [{ address: '127.0.0.1', family: 4 as const }]); + const request = createSafeFederationRequester({ development: true, dnsResolver }); + const localhostUrl = server.baseUrl.replace('127.0.0.1', 'localhost'); + + const response = await request(localhostUrl); + + expect(response.json()).toEqual({ pinned: true }); + expect(dnsResolver).toHaveBeenCalledOnce(); + expect(dnsResolver).toHaveBeenCalledWith('localhost'); + }); + + it('applies the deadline while DNS resolution is still pending', async () => { + const request = createSafeFederationRequester({ + development: false, + dnsResolver: () => new Promise(() => undefined), + }); + + await expect( + request('https://node.synapsis.social/api/chat/receive', { timeoutMs: 20 }) + ).rejects.toSatisfy(expectCode('TIMEOUT')); + }); + + it.each([ + 'http://localhost:1/api/chat/receive', + 'http://127.0.0.1:1/api/chat/receive', + 'http://[::1]:1/api/chat/receive', + ])('permits only exact HTTP loopback forms in development: %s', async (url) => { + const request = createSafeFederationRequester({ + development: true, + dnsResolver: async () => [{ address: '127.0.0.1', family: 4 }], + }); + + // Connection refusal confirms policy and pinning passed; unsafe URL/DNS errors do not. + await expect(request(url, { timeoutMs: 100 })).rejects.toSatisfy( + (error: unknown) => + error instanceof SafeFederationError && + (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') + ); + }); + + it.each([ + 'http://localhost.evil.com:3000/', + 'http://127.0.0.2:3000/', + 'http://2130706433:3000/', + 'http://[::ffff:127.0.0.1]:3000/', + 'https://localhost:3000/', + ])('rejects lookalike or non-HTTP development targets: %s', async (url) => { + const request = createSafeFederationRequester({ development: true }); + await expect(request(url)).rejects.toSatisfy(expectCode('UNSAFE_URL')); + }); +}); + +describe('E2EE federation HTTP behavior', () => { + it('supports GET, bounded buffering, UTF-8 text, and JSON parsing', async () => { + const server = await startServer((_request, response) => { + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ ok: true, message: 'encrypted' })); + }); + const request = createSafeFederationRequester({ development: true }); + + const response = await request(`${server.baseUrl}/keys`); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/json'); + expect(response.text()).toBe('{"ok":true,"message":"encrypted"}'); + expect(response.json()).toEqual({ ok: true, message: 'encrypted' }); + }); + + it('supports POST headers and a body while owning framing headers', async () => { + const server = await startServer((incoming, response) => { + const chunks: Buffer[] = []; + incoming.on('data', (chunk: Buffer) => chunks.push(chunk)); + incoming.on('end', () => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + method: incoming.method, + signature: incoming.headers['x-synapsis-signature'], + body: Buffer.concat(chunks).toString('utf8'), + contentLength: incoming.headers['content-length'], + acceptEncoding: incoming.headers['accept-encoding'], + }) + ); + }); + }); + const request = createSafeFederationRequester({ development: true }); + const body = JSON.stringify({ ciphertext: 'abc123' }); + + const response = await request(`${server.baseUrl}/receive`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-synapsis-signature': 'signed', + }, + body, + }); + + expect(response.json()).toEqual({ + method: 'POST', + signature: 'signed', + body, + contentLength: String(Buffer.byteLength(body)), + acceptEncoding: 'identity', + }); + }); + + it('returns redirects without following them', async () => { + let destinationHits = 0; + const server = await startServer((incoming, response) => { + if (incoming.url === '/destination') destinationHits += 1; + response.writeHead(incoming.url === '/redirect' ? 302 : 200, { + location: '/destination', + 'content-type': 'application/json', + }); + response.end(JSON.stringify({ redirected: incoming.url !== '/redirect' })); + }); + const request = createSafeFederationRequester({ development: true }); + + const response = await request(`${server.baseUrl}/redirect`); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe('/destination'); + expect(destinationHits).toBe(0); + }); + + it('rejects streamed responses that exceed the byte limit', async () => { + const server = await startServer((_incoming, response) => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.write('{"padding":"'); + response.end(`${'x'.repeat(128)}"}`); + }); + const request = createSafeFederationRequester({ development: true }); + + await expect( + request(`${server.baseUrl}/large`, { maxResponseBytes: 32 }) + ).rejects.toSatisfy(expectCode('RESPONSE_TOO_LARGE')); + }); + + it('enforces one deadline across the request', async () => { + const server = await startServer((_incoming, response) => { + setTimeout(() => { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{"late":true}'); + }, 150); + }); + const request = createSafeFederationRequester({ development: true }); + + await expect( + request(`${server.baseUrl}/slow`, { timeoutMs: 25 }) + ).rejects.toSatisfy(expectCode('TIMEOUT')); + }); + + it('rejects compressed, mislabeled, and malformed JSON responses', async () => { + const server = await startServer((incoming, response) => { + if (incoming.url === '/compressed') { + response.writeHead(200, { + 'content-type': 'application/json', + 'content-encoding': 'gzip', + }); + response.end('not actually compressed'); + return; + } + response.writeHead(200, { + 'content-type': incoming.url === '/text' ? 'text/plain' : 'application/json', + }); + response.end(incoming.url === '/text' ? '{"valid":true}' : '{broken'); + }); + const request = createSafeFederationRequester({ development: true }); + + await expect(request(`${server.baseUrl}/compressed`)).rejects.toSatisfy( + expectCode('UNEXPECTED_CONTENT_ENCODING') + ); + const textResponse = await request(`${server.baseUrl}/text`); + expect(() => textResponse.json()).toThrowError( + expect.objectContaining({ code: 'UNEXPECTED_CONTENT_TYPE' }) + ); + const malformedResponse = await request(`${server.baseUrl}/malformed`); + expect(() => malformedResponse.json()).toThrowError( + expect.objectContaining({ code: 'INVALID_JSON' }) + ); + }); + + it('forbids caller-controlled routing and framing headers', async () => { + const request = createSafeFederationRequester({ development: true }); + + await expect( + request('http://127.0.0.1:1/', { headers: { host: '169.254.169.254' } }) + ).rejects.toSatisfy(expectCode('INVALID_HEADER')); + await expect( + request('http://127.0.0.1:1/', { headers: { 'content-length': '999' } }) + ).rejects.toSatisfy(expectCode('INVALID_HEADER')); + }); +}); diff --git a/src/lib/swarm/safe-federation-http.ts b/src/lib/swarm/safe-federation-http.ts new file mode 100644 index 0000000..19b7c64 --- /dev/null +++ b/src/lib/swarm/safe-federation-http.ts @@ -0,0 +1,672 @@ +import { lookup as nodeLookup } from 'node:dns/promises'; +import { + request as httpRequest, + validateHeaderName, + validateHeaderValue, + type IncomingHttpHeaders, + type RequestOptions, +} from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { isIP, type LookupFunction } from 'node:net'; +import { parse as parseDomain } from 'tldts'; + +export const E2EE_FEDERATION_TIMEOUT_MS = 8_000; +export const E2EE_FEDERATION_MAX_RESPONSE_BYTES = 256 * 1024; +export const E2EE_FEDERATION_MAX_REQUEST_BYTES = 1024 * 1024; + +const MAX_CONFIGURABLE_RESPONSE_BYTES = 1024 * 1024; +const JSON_CONTENT_TYPE = /^(?:application\/json|[^/\s]+\/[^;\s]+\+json)(?:\s*;|$)/i; +const EXACT_DEVELOPMENT_LOOPBACK_URL = + /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?(?:[/?#]|$)/i; +const FORBIDDEN_REQUEST_HEADERS = new Set([ + 'accept-encoding', + 'connection', + 'content-length', + 'host', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +export type SafeFederationErrorCode = + | 'ABORTED' + | 'DNS_RESOLUTION_FAILED' + | 'INVALID_HEADER' + | 'INVALID_JSON' + | 'INVALID_OPTION' + | 'INVALID_URL' + | 'NETWORK_ERROR' + | 'REQUEST_TOO_LARGE' + | 'RESPONSE_TOO_LARGE' + | 'TIMEOUT' + | 'UNEXPECTED_CONTENT_ENCODING' + | 'UNEXPECTED_CONTENT_TYPE' + | 'UNSAFE_DNS_RESULT' + | 'UNSAFE_URL'; + +export class SafeFederationError extends Error { + readonly code: SafeFederationErrorCode; + override readonly cause?: unknown; + + constructor(code: SafeFederationErrorCode, message: string, cause?: unknown) { + super(message); + this.name = 'SafeFederationError'; + this.code = code; + this.cause = cause; + } +} + +export type FederationRequestHeaders = Readonly< + Record +>; + +export interface SafeFederationRequestOptions { + method?: 'GET' | 'POST'; + headers?: FederationRequestHeaders; + body?: string | Uint8Array; + /** May only shorten, never extend, the eight-second safety timeout. */ + timeoutMs?: number; + /** May only lower the one-MiB hard response ceiling. */ + maxResponseBytes?: number; + signal?: AbortSignal; +} + +export interface SafeFederationResponse { + readonly url: string; + readonly status: number; + readonly headers: Readonly; + readonly body: Buffer; + text(): string; + json(): unknown; +} + +export interface FederationDnsAddress { + readonly address: string; + readonly family: 4 | 6; +} + +export type FederationDnsResolver = ( + hostname: string +) => Promise; + +export interface SafeFederationRequesterDependencies { + /** Dependency seams are intended for focused tests, not request-specific policy. */ + readonly dnsResolver?: FederationDnsResolver; + readonly development?: boolean; +} + +interface ValidatedFederationTarget { + readonly url: URL; + readonly hostname: string; + readonly loopbackDevelopmentTarget: boolean; +} + +interface PinnedFederationTarget extends ValidatedFederationTarget { + readonly address: string; + readonly family: 4 | 6; + readonly lookup: LookupFunction; +} + +function invalidOption(message: string): never { + throw new SafeFederationError('INVALID_OPTION', message); +} + +function unwrapUrlHostname(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; +} + +function isExactLoopbackAddress(address: string): boolean { + const normalized = address.toLowerCase(); + return normalized === '127.0.0.1' || normalized === '::1'; +} + +function parseIpv4(address: string): number[] | null { + if (isIP(address) !== 4) return null; + + const octets = address.split('.').map(Number); + return octets.length === 4 && octets.every((octet) => octet >= 0 && octet <= 255) + ? octets + : null; +} + +function isPublicIpv4Address(address: string): boolean { + const octets = parseIpv4(address); + if (!octets) return false; + + const [first, second, third] = octets; + return !( + first === 0 || + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 0 && third === 0) || + (first === 192 && second === 0 && third === 2) || + (first === 192 && second === 88 && third === 99) || + (first === 192 && second === 168) || + (first === 198 && (second === 18 || second === 19)) || + (first === 198 && second === 51 && third === 100) || + (first === 203 && second === 0 && third === 113) || + first >= 224 + ); +} + +function expandIpv6(address: string): number[] | null { + let normalized = address.toLowerCase(); + const zoneIndex = normalized.indexOf('%'); + if (zoneIndex !== -1) return null; + + const ipv4TailMatch = normalized.match(/(?:^|:)(\d+\.\d+\.\d+\.\d+)$/); + if (ipv4TailMatch) { + const octets = parseIpv4(ipv4TailMatch[1]); + if (!octets) return null; + normalized = `${normalized.slice(0, -ipv4TailMatch[1].length)}${( + (octets[0] << 8) | + octets[1] + ).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`; + } + + if (isIP(normalized) !== 6) return null; + + const doubleColonParts = normalized.split('::'); + if (doubleColonParts.length > 2) return null; + + const left = doubleColonParts[0] ? doubleColonParts[0].split(':') : []; + const right = doubleColonParts[1] ? doubleColonParts[1].split(':') : []; + const missing = 8 - left.length - right.length; + if (missing < 0 || (doubleColonParts.length === 1 && missing !== 0)) return null; + + const parts = [ + ...left, + ...Array.from({ length: missing }, () => '0'), + ...right, + ].map((part) => Number.parseInt(part || '0', 16)); + + return parts.length === 8 && parts.every((part) => Number.isInteger(part) && part <= 0xffff) + ? parts + : null; +} + +function hasIpv6Prefix(parts: readonly number[], prefix: readonly number[], bits: number): boolean { + let remaining = bits; + + for (let index = 0; remaining > 0; index += 1) { + const comparedBits = Math.min(remaining, 16); + const mask = comparedBits === 16 ? 0xffff : (0xffff << (16 - comparedBits)) & 0xffff; + if ((parts[index] & mask) !== ((prefix[index] ?? 0) & mask)) return false; + remaining -= comparedBits; + } + + return true; +} + +function isPublicIpv6Address(address: string): boolean { + const parts = expandIpv6(address); + if (!parts) return false; + + // Ordinary globally routed IPv6 unicast space is currently 2000::/3. + // Requiring that allocation also rejects unassigned/reserved top-level space. + if (!hasIpv6Prefix(parts, [0x2000], 3)) return false; + + const specialPrefixes: ReadonlyArray = [ + [[0x0000], 96], // unspecified, loopback, and IPv4-compatible + [[0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0xffff], 96], // IPv4-mapped + [[0x0064, 0xff9b], 96], // well-known NAT64 translation prefix + [[0x0064, 0xff9b, 0x0001], 48], // local-use NAT64 translation prefix + [[0x0100], 64], // discard-only + [[0x2001, 0x0000], 32], // Teredo + [[0x2001, 0x0002], 48], // benchmarking + [[0x2001, 0x0db8], 32], // documentation (2001:db8::/32) + [[0x2001, 0x0010], 28], // deprecated ORCHID + [[0x2001, 0x0020], 28], // ORCHIDv2 + [[0x2001, 0x0030], 28], // Drone Remote ID protocol assignments + [[0x2002], 16], // 6to4 + [[0x3fff], 20], // documentation + [[0x5f00], 16], // segment-routing SIDs + [[0xfc00], 7], // unique-local + [[0xfe80], 10], // link-local + [[0xfec0], 10], // deprecated site-local + [[0xff00], 8], // multicast + ]; + + return !specialPrefixes.some(([prefix, bits]) => hasIpv6Prefix(parts, prefix, bits)); +} + +/** True only for ordinary globally routable IPv4/IPv6 addresses. */ +export function isPublicFederationAddress(address: string): boolean { + const family = isIP(address); + if (family === 4) return isPublicIpv4Address(address); + if (family === 6) return isPublicIpv6Address(address); + return false; +} + +function validateFederationUrl( + input: string, + development: boolean +): ValidatedFederationTarget { + const trimmed = input.trim(); + let url: URL; + + try { + url = new URL(trimmed); + } catch (error) { + throw new SafeFederationError('INVALID_URL', 'Federation target is not a valid URL', error); + } + + if (url.username || url.password) { + throw new SafeFederationError('UNSAFE_URL', 'Federation URLs cannot contain credentials'); + } + + const hostname = unwrapUrlHostname(url.hostname).toLowerCase(); + const exactDevelopmentLoopback = + development && + url.protocol === 'http:' && + EXACT_DEVELOPMENT_LOOPBACK_URL.test(trimmed) && + (hostname === 'localhost' || isExactLoopbackAddress(hostname)); + + if (exactDevelopmentLoopback) { + return { url, hostname, loopbackDevelopmentTarget: true }; + } + + if (url.protocol !== 'https:') { + throw new SafeFederationError( + 'UNSAFE_URL', + 'Federation targets must use HTTPS (except exact loopback hosts in development)' + ); + } + + if (isIP(hostname) !== 0) { + throw new SafeFederationError('UNSAFE_URL', 'Federation targets must use a public hostname'); + } + + const parsed = parseDomain(hostname, { + allowPrivateDomains: false, + detectSpecialUse: true, + }); + if (!parsed.domain || parsed.isIcann !== true || parsed.isIp || parsed.isSpecialUse) { + throw new SafeFederationError('UNSAFE_URL', 'Federation target is not a public ICANN hostname'); + } + + return { url, hostname, loopbackDevelopmentTarget: false }; +} + +function createPinnedLookup( + expectedHostname: string, + address: string, + family: 4 | 6 +): LookupFunction { + return (( + requestedHostname: string, + options: number | { all?: boolean }, + callback: ( + error: NodeJS.ErrnoException | null, + addressOrAddresses: string | FederationDnsAddress[], + resultFamily?: number + ) => void + ) => { + if (requestedHostname.toLowerCase() !== expectedHostname) { + const error = new Error('Unexpected hostname passed to pinned DNS lookup') as NodeJS.ErrnoException; + error.code = 'ENOTFOUND'; + callback(error, '', 0); + return; + } + + if (typeof options === 'object' && options.all) { + callback(null, [{ address, family }]); + return; + } + + callback(null, address, family); + }) as LookupFunction; +} + +const defaultDnsResolver: FederationDnsResolver = async (hostname) => { + const addresses = await nodeLookup(hostname, { all: true, verbatim: true }); + return addresses.filter( + (address): address is FederationDnsAddress => address.family === 4 || address.family === 6 + ); +}; + +async function resolveAndPinTarget( + target: ValidatedFederationTarget, + resolver: FederationDnsResolver +): Promise { + let addresses: readonly FederationDnsAddress[]; + + if (target.hostname === '127.0.0.1') { + addresses = [{ address: '127.0.0.1', family: 4 }]; + } else if (target.hostname === '::1') { + addresses = [{ address: '::1', family: 6 }]; + } else { + try { + addresses = await resolver(target.hostname); + } catch (error) { + throw new SafeFederationError( + 'DNS_RESOLUTION_FAILED', + 'Federation target DNS resolution failed', + error + ); + } + } + + if (addresses.length === 0) { + throw new SafeFederationError( + 'DNS_RESOLUTION_FAILED', + 'Federation target DNS resolution returned no addresses' + ); + } + + const allAddressesAllowed = addresses.every(({ address, family }) => { + if (isIP(address) !== family) return false; + return target.loopbackDevelopmentTarget + ? isExactLoopbackAddress(address) + : isPublicFederationAddress(address); + }); + if (!allAddressesAllowed) { + throw new SafeFederationError( + 'UNSAFE_DNS_RESULT', + 'Federation target DNS resolution included an unsafe address' + ); + } + + const pinned = addresses[0]; + return { + ...target, + address: pinned.address, + family: pinned.family, + lookup: createPinnedLookup(target.hostname, pinned.address, pinned.family), + }; +} + +function validatePositiveInteger( + value: number | undefined, + fallback: number, + maximum: number, + label: string +): number { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + return invalidOption(`${label} must be a positive integer no greater than ${maximum}`); + } + return value; +} + +function normalizeHeaders( + input: FederationRequestHeaders | undefined, + body: Buffer | undefined +): Record { + const headers: Record = { + accept: 'application/json', + 'accept-encoding': 'identity', + }; + + for (const [rawName, rawValue] of Object.entries(input ?? {})) { + if (rawValue === undefined) continue; + const name = rawName.toLowerCase(); + if (FORBIDDEN_REQUEST_HEADERS.has(name)) { + throw new SafeFederationError('INVALID_HEADER', `Request header ${rawName} is not allowed`); + } + + try { + validateHeaderName(name); + if (Array.isArray(rawValue)) { + rawValue.forEach((value) => validateHeaderValue(name, value)); + } else { + validateHeaderValue(name, rawValue as string); + } + } catch (error) { + throw new SafeFederationError('INVALID_HEADER', `Request header ${rawName} is invalid`, error); + } + headers[name] = Array.isArray(rawValue) ? [...rawValue] : (rawValue as string); + } + + if (body) headers['content-length'] = String(body.byteLength); + return headers; +} + +function createResponse( + url: string, + status: number, + incomingHeaders: IncomingHttpHeaders, + body: Buffer +): SafeFederationResponse { + const headers = Object.freeze( + Object.fromEntries( + Object.entries(incomingHeaders).map(([name, value]) => [ + name, + Array.isArray(value) ? Object.freeze([...value]) : value, + ]) + ) as IncomingHttpHeaders + ); + const storedBody = Buffer.from(body); + const exposedBody = Buffer.from(storedBody); + + return Object.freeze({ + url, + status, + headers, + body: exposedBody, + text(): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(storedBody); + } catch (error) { + throw new SafeFederationError('INVALID_JSON', 'Response body is not valid UTF-8', error); + } + }, + json(): unknown { + const contentType = headers['content-type']; + const normalizedContentType = Array.isArray(contentType) ? contentType[0] : contentType; + if (!normalizedContentType || !JSON_CONTENT_TYPE.test(normalizedContentType)) { + throw new SafeFederationError( + 'UNEXPECTED_CONTENT_TYPE', + 'Response does not declare a JSON content type' + ); + } + + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(storedBody); + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + return JSON.parse(text) as unknown; + } catch (error) { + throw new SafeFederationError('INVALID_JSON', 'Response body is not valid JSON', error); + } + }, + }); +} + +function abortable(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', onAbort); + reject(error); + } + ); + }); +} + +function requestPinnedTarget( + target: PinnedFederationTarget, + method: 'GET' | 'POST', + headers: Record, + body: Buffer | undefined, + maxResponseBytes: number, + signal: AbortSignal +): Promise { + return new Promise((resolve, reject) => { + const requestOptions: RequestOptions = { + protocol: target.url.protocol, + hostname: target.hostname, + port: target.url.port || undefined, + method, + path: `${target.url.pathname}${target.url.search}`, + headers, + lookup: target.lookup, + agent: false, + maxHeaderSize: 16 * 1024, + signal, + }; + + const makeRequest = target.url.protocol === 'https:' ? httpsRequest : httpRequest; + const request = makeRequest(requestOptions, (response) => { + const contentEncoding = response.headers['content-encoding']; + if (contentEncoding && contentEncoding.toLowerCase() !== 'identity') { + response.destroy(); + reject( + new SafeFederationError( + 'UNEXPECTED_CONTENT_ENCODING', + 'Compressed federation responses are not accepted' + ) + ); + return; + } + + const declaredLength = Number(response.headers['content-length']); + if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) { + response.destroy(); + reject( + new SafeFederationError( + 'RESPONSE_TOO_LARGE', + `Federation response exceeds the ${maxResponseBytes}-byte limit` + ) + ); + return; + } + + const chunks: Buffer[] = []; + let receivedBytes = 0; + response.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + receivedBytes += buffer.byteLength; + if (receivedBytes > maxResponseBytes) { + response.destroy(); + reject( + new SafeFederationError( + 'RESPONSE_TOO_LARGE', + `Federation response exceeds the ${maxResponseBytes}-byte limit` + ) + ); + return; + } + chunks.push(buffer); + }); + response.once('end', () => { + resolve( + createResponse( + target.url.toString(), + response.statusCode ?? 0, + response.headers, + Buffer.concat(chunks, receivedBytes) + ) + ); + }); + response.once('error', reject); + }); + + request.once('error', (error) => reject(error)); + if (body) request.write(body); + request.end(); + }); +} + +/** + * Build the E2EE federation requester. The default export below is locked to + * runtime environment policy; the factory's seams exist for deterministic tests. + */ +export function createSafeFederationRequester( + dependencies: SafeFederationRequesterDependencies = {} +): (url: string, options?: SafeFederationRequestOptions) => Promise { + const resolver = dependencies.dnsResolver ?? defaultDnsResolver; + const development = dependencies.development ?? process.env.NODE_ENV === 'development'; + + return async (url, options = {}) => { + const method = options.method ?? 'GET'; + if (method !== 'GET' && method !== 'POST') { + return invalidOption('Federation requests only support GET and POST'); + } + + if (method === 'GET' && options.body !== undefined) { + return invalidOption('GET federation requests cannot contain a body'); + } + + const timeoutMs = validatePositiveInteger( + options.timeoutMs, + E2EE_FEDERATION_TIMEOUT_MS, + E2EE_FEDERATION_TIMEOUT_MS, + 'timeoutMs' + ); + const maxResponseBytes = validatePositiveInteger( + options.maxResponseBytes, + E2EE_FEDERATION_MAX_RESPONSE_BYTES, + MAX_CONFIGURABLE_RESPONSE_BYTES, + 'maxResponseBytes' + ); + + const body = + options.body === undefined + ? undefined + : typeof options.body === 'string' + ? Buffer.from(options.body, 'utf8') + : Buffer.from(options.body); + if (body && body.byteLength > E2EE_FEDERATION_MAX_REQUEST_BYTES) { + throw new SafeFederationError( + 'REQUEST_TOO_LARGE', + `Federation request exceeds the ${E2EE_FEDERATION_MAX_REQUEST_BYTES}-byte limit` + ); + } + + const headers = normalizeHeaders(options.headers, body); + const target = validateFederationUrl(url, development); + const controller = new AbortController(); + const timeoutError = new SafeFederationError( + 'TIMEOUT', + `Federation request exceeded ${timeoutMs}ms` + ); + const timeout = setTimeout(() => controller.abort(timeoutError), timeoutMs); + timeout.unref?.(); + + const onExternalAbort = () => + controller.abort(new SafeFederationError('ABORTED', 'Federation request was aborted')); + if (options.signal?.aborted) onExternalAbort(); + else options.signal?.addEventListener('abort', onExternalAbort, { once: true }); + + try { + const pinned = await abortable(resolveAndPinTarget(target, resolver), controller.signal); + return await requestPinnedTarget( + pinned, + method, + headers, + body, + maxResponseBytes, + controller.signal + ); + } catch (error) { + if (controller.signal.aborted) { + throw controller.signal.reason instanceof SafeFederationError + ? controller.signal.reason + : new SafeFederationError('ABORTED', 'Federation request was aborted', error); + } + if (error instanceof SafeFederationError) throw error; + throw new SafeFederationError('NETWORK_ERROR', 'Federation request failed', error); + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener('abort', onExternalAbort); + } + }; +} + +export const safeFederationRequest = createSafeFederationRequester();