Add DNS-pinned, SSRF-safe E2EE federation HTTP client with bounded transport and focused tests

Hop-State: A_06FPBGR5V0SYEQ98K4B3CK0
Hop-Proposal: R_06FPBGQ08SYCC3FFX60C2WR
Hop-Task: T_06FPBE04RGFEEZ273R2YQWG
Hop-Attempt: AT_06FPBE04RGAW7YS4NVENDKR
This commit is contained in:
2026-07-15 05:31:47 -07:00
committed by Hop
parent c1f3afa103
commit 219a40bea4
2 changed files with 1014 additions and 0 deletions
+342
View File
@@ -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<void>;
}
const openServers = new Set<TestServer>();
async function startServer(
handler: (request: IncomingMessage, response: ServerResponse) => void
): Promise<TestServer> {
const server = createServer(handler);
await new Promise<void>((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<void>((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'));
});
});
+672
View File
@@ -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<string, string | readonly string[] | undefined>
>;
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<IncomingHttpHeaders>;
readonly body: Buffer;
text(): string;
json(): unknown;
}
export interface FederationDnsAddress {
readonly address: string;
readonly family: 4 | 6;
}
export type FederationDnsResolver = (
hostname: string
) => Promise<readonly FederationDnsAddress[]>;
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<readonly [readonly number[], number]> = [
[[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<PinnedFederationTarget> {
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<string, string | string[]> {
const headers: Record<string, string | string[]> = {
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<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((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<string, string | string[]>,
body: Buffer | undefined,
maxResponseBytes: number,
signal: AbortSignal
): Promise<SafeFederationResponse> {
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<SafeFederationResponse> {
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();