Files
Synapsis/src/lib/swarm/safe-federation-http.ts
T
cyph3rasi b46be5c076 Reconcile the accepted safe federation helper with E2EE integration hardening and complete PIN-based encrypted DMs
Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0
Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908
Hop-Task: T_06FPAWA3279RBMJAR0BHM10
Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
2026-07-15 06:40:06 -07:00

677 lines
21 KiB
TypeScript

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',
'authorization',
'connection',
'content-length',
'cookie',
'expect',
'host',
'proxy-authorization',
'proxy-connection',
'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();