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:
@@ -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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user