b46be5c076
Hop-State: A_06FPC0CGS3F7SJG3BGW0YF0 Hop-Proposal: R_06FPC0BK6YEV7XASNM7C908 Hop-Task: T_06FPAWA3279RBMJAR0BHM10 Hop-Attempt: AT_06FPBZWSBFTB9B5YH9JY9QR
677 lines
21 KiB
TypeScript
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();
|