Files
fluxer/fluxer_desktop/src/main/DesktopOutboundHTTP.ts
T

1399 lines
45 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {Buffer} from 'node:buffer';
import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import {isIPv4, isIPv6, type LookupFunction} from 'node:net';
import {Readable, Transform} from 'node:stream';
import type {ReadableStream as NodeReadableStream} from 'node:stream/web';
import {createChildLogger} from '@electron/common/Logger';
import {
type DesktopProxyResolver,
type DesktopSessionHTTPSender,
isDirectProxyRoute,
resolveDesktopSessionProxy,
sendThroughDesktopSession,
} from '@electron/main/DesktopSessionHTTP';
import {resolveDesktopTrustedCertificates} from '@electron/main/DesktopTrustedCertificates';
import {normalizeHTTPNetworkOrigin} from '@fluxer/instance_bootstrap/src/NetworkOrigin';
const logger = createChildLogger('DesktopOutboundHTTP');
const DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT = 32;
const DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS = 16;
const DESKTOP_OUTBOUND_HTTP_RESOLUTION_TIMEOUT_MS = 10_000;
const DESKTOP_OUTBOUND_HTTP_MAX_TARGET_URL_BYTES = 16 * 1024;
const DESKTOP_OUTBOUND_HTTP_MAX_REDIRECT_LOCATION_BYTES = 4096;
const DESKTOP_OUTBOUND_HTTP_MAX_ORIGINS = 2048;
const DESKTOP_OUTBOUND_HTTP_MAX_ORIGIN_REQUESTS_IN_FLIGHT = 128;
const DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE = 64;
const DESKTOP_OUTBOUND_HTTP_MAX_PENDING_RESOLUTIONS = 64;
const DESKTOP_OUTBOUND_HTTP_MAX_REQUEST_BODY_BYTES = 1024 * 1024;
const DESKTOP_OUTBOUND_HTTP_STALE_SOCKET_ATTEMPTS = 1;
const DESKTOP_OUTBOUND_HTTP_UNREACHABLE_ADDRESS_CODES: ReadonlySet<string> = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
'ETIMEDOUT',
]);
const DESKTOP_OUTBOUND_HTTP_STALE_SOCKET_CODES: ReadonlySet<string> = new Set(['ECONNRESET', 'EPIPE']);
const DESKTOP_OUTBOUND_HTTP_IDEMPOTENT_METHODS: ReadonlySet<string> = new Set([
'DELETE',
'GET',
'HEAD',
'OPTIONS',
'PUT',
'TRACE',
]);
const DESKTOP_OUTBOUND_HTTP_BLOCKED_MESSAGE = 'The requested address could not be reached';
const DESKTOP_OUTBOUND_HTTP_TRANSPORT_MESSAGE = 'The request could not be completed';
const DESKTOP_OUTBOUND_HTTP_TIMEOUT_MESSAGE = 'The request timed out';
const DESKTOP_OUTBOUND_HTTP_CAPACITY_MESSAGE = 'Too many downloads are already in progress';
const IPV6_GROUP_COUNT = 8;
const DesktopOutboundBlockReason = Object.freeze({
INSECURE_TRANSPORT: 'insecure-transport',
INVALID_TARGET: 'invalid-target',
LOOKUP_HOSTNAME_MISMATCH: 'lookup-hostname-mismatch',
NON_PUBLIC_ADDRESS: 'non-public-address',
NO_USABLE_ADDRESS: 'no-usable-address',
RESOLUTION_FAILED: 'resolution-failed',
RESOLUTION_TIMEOUT: 'resolution-timeout',
} as const);
type DesktopOutboundBlockReason = (typeof DesktopOutboundBlockReason)[keyof typeof DesktopOutboundBlockReason];
export const DesktopAddressRequirement = Object.freeze({
ANY: 'any',
PUBLIC: 'public',
} as const);
export type DesktopAddressRequirement = (typeof DesktopAddressRequirement)[keyof typeof DesktopAddressRequirement];
const DesktopOriginAddressScope = Object.freeze({
NON_PUBLIC: 'non-public',
PUBLIC: 'public',
} as const);
type DesktopOriginAddressScope = (typeof DesktopOriginAddressScope)[keyof typeof DesktopOriginAddressScope];
export const DesktopOriginTrust = Object.freeze({
BOUND: 'bound',
REGISTERED: 'registered',
} as const);
export type DesktopOriginTrust = (typeof DesktopOriginTrust)[keyof typeof DesktopOriginTrust];
const HAPPY_EYEBALLS_CONNECT_OPTIONS = Object.freeze({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 250,
});
interface PinnedAddress {
readonly address: string;
readonly family: 4 | 6;
}
type DesktopHostAddressResolver = (hostname: string) => Promise<ReadonlyArray<string>>;
interface DesktopOriginAddressBinding {
readonly address: PinnedAddress;
readonly addresses: ReadonlyArray<PinnedAddress>;
readonly origin: string;
readonly scope: DesktopOriginAddressScope;
readonly unreachable: boolean;
}
interface DesktopPendingOriginBinding {
readonly operation: Promise<DesktopOriginAddressBinding>;
requirement: DesktopAddressRequirement;
}
export interface DesktopOutboundHTTPRequest {
readonly body: Uint8Array | ReadableStream<Uint8Array> | null;
readonly expectedOrigin: string;
readonly headers: Readonly<Record<string, string>> | null;
readonly maximumRequestBodyBytes?: number;
readonly method: string;
readonly originTrust: DesktopOriginTrust;
readonly serviceName: string;
readonly signal: AbortSignal | null;
readonly timeoutMs: number;
readonly url: string;
}
interface DesktopAnchoredOriginRegistration {
readonly anchorOrigin: string;
readonly origins: ReadonlyArray<string>;
readonly unresolvedAnchorRequirement?: DesktopAddressRequirement | null;
}
interface DesktopOutboundHTTPOptions {
readonly resolveHostAddresses?: DesktopHostAddressResolver;
readonly resolveProxy?: DesktopProxyResolver;
readonly sendThroughSession?: DesktopSessionHTTPSender;
readonly trustedCertificates?: ReadonlyArray<string>;
}
interface DesktopOutboundGETRequest {
readonly context: string;
readonly timeoutMs: number;
readonly url: URL;
}
export interface DesktopOutboundHTTPMessage {
readonly headers: http.IncomingHttpHeaders;
readonly message: Readable;
readonly status: number;
readonly statusText: string;
readonly url: URL;
}
interface BoundedMessageRead {
readonly declaredBytes: number | null;
readonly description: string;
readonly maxBytes: number;
readonly maxChunks: number;
readonly message: Readable;
}
class DesktopOutboundHTTPBlockedError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_BLOCKED_MESSAGE);
this.name = 'DesktopOutboundHTTPBlockedError';
}
}
class DesktopOutboundHTTPTransportError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_TRANSPORT_MESSAGE);
this.name = 'DesktopOutboundHTTPTransportError';
}
}
class DesktopOutboundHTTPTimeoutError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_TIMEOUT_MESSAGE);
this.name = 'DesktopOutboundHTTPTimeoutError';
}
}
class DesktopOutboundHTTPCapacityError extends Error {
public constructor(limit?: string) {
super(limit == null ? DESKTOP_OUTBOUND_HTTP_CAPACITY_MESSAGE : `Desktop outbound HTTP exceeded its ${limit} limit`);
this.name = 'DesktopOutboundHTTPCapacityError';
}
}
class DesktopOutboundHTTPClosedError extends Error {
public constructor() {
super('Desktop outbound HTTP is closed');
this.name = 'DesktopOutboundHTTPClosedError';
}
}
class DesktopOutboundHTTPInvalidOriginError extends TypeError {
public constructor(origin: string) {
super(`Desktop outbound HTTP origin is not a canonical http(s) origin: ${origin}`);
this.name = 'DesktopOutboundHTTPInvalidOriginError';
}
}
class DesktopOutboundHTTPInvalidTargetError extends TypeError {
public constructor(url: string) {
super(`Desktop outbound HTTP target is not a canonical URL under its declared origin: ${url}`);
this.name = 'DesktopOutboundHTTPInvalidTargetError';
}
}
class DesktopOutboundHTTPInsecureTransportError extends Error {
public constructor(origin: string) {
super(`Desktop outbound HTTP requires https for the publicly routable origin ${origin}`);
this.name = 'DesktopOutboundHTTPInsecureTransportError';
}
}
class DesktopOutboundHTTPPublicAddressRequiredError extends Error {
public constructor(origin: string) {
super(`A publicly reachable instance cannot authorize the privately resolving origin ${origin}`);
this.name = 'DesktopOutboundHTTPPublicAddressRequiredError';
}
}
class DesktopOutboundHTTPOriginNotRegisteredError extends Error {
public constructor(origin: string) {
super(`Desktop outbound HTTP origin is not registered to a discovered instance: ${origin}`);
this.name = 'DesktopOutboundHTTPOriginNotRegisteredError';
}
}
class DesktopOutboundHTTPMixedAddressScopeError extends Error {
public constructor(origin: string) {
super(`Desktop outbound HTTP origin ${origin} resolved to both public and non-public addresses`);
this.name = 'DesktopOutboundHTTPMixedAddressScopeError';
}
}
class DesktopOutboundHTTPEmptyResolutionError extends Error {
public constructor(origin: string) {
super(`Desktop outbound HTTP origin ${origin} resolved to no usable address`);
this.name = 'DesktopOutboundHTTPEmptyResolutionError';
}
}
class DesktopOutboundHTTPResolutionTimeoutError extends Error {
public constructor(hostname: string) {
super(`Desktop outbound HTTP resolution of ${hostname} exceeded ${DESKTOP_OUTBOUND_HTTP_RESOLUTION_TIMEOUT_MS} ms`);
this.name = 'DesktopOutboundHTTPResolutionTimeoutError';
}
}
class DesktopOutboundHTTPRequestTimeoutError extends Error {
public constructor(url: string, timeoutMs: number) {
super(`Desktop outbound HTTP request to ${url} timed out after ${timeoutMs} ms`);
this.name = 'DesktopOutboundHTTPRequestTimeoutError';
}
}
class DesktopOutboundHTTPRequestAbortedError extends Error {
public constructor(url: string) {
super(`Desktop outbound HTTP request to ${url} was aborted by its caller`);
this.name = 'DesktopOutboundHTTPRequestAbortedError';
}
}
class DesktopOutboundHTTPRequestBodyLimitError extends Error {
public constructor(maximumBytes: number) {
super(`Desktop outbound HTTP request body exceeds ${maximumBytes} bytes`);
this.name = 'DesktopOutboundHTTPRequestBodyLimitError';
}
}
class BoundedMessageByteLimitError extends RangeError {
public constructor(description: string, maxBytes: number) {
super(`${description} exceeds ${maxBytes} bytes`);
this.name = 'BoundedMessageByteLimitError';
}
}
class BoundedMessageChunkLimitError extends RangeError {
public constructor(description: string, maxChunks: number) {
super(`${description} exceeds ${maxChunks} response chunks`);
this.name = 'BoundedMessageChunkLimitError';
}
}
class InvalidContentLengthError extends TypeError {
public constructor(description: string) {
super(`${description} has an invalid Content-Length header`);
this.name = 'InvalidContentLengthError';
}
}
class PinnedLookupHostnameMismatchError extends Error {
public constructor() {
super(DESKTOP_OUTBOUND_HTTP_BLOCKED_MESSAGE);
this.name = 'PinnedLookupHostnameMismatchError';
}
}
function blocked(
reason: DesktopOutboundBlockReason,
context: string,
hostname: string,
): DesktopOutboundHTTPBlockedError {
logger.warn('Blocked outbound request', {context, hostname, reason});
return new DesktopOutboundHTTPBlockedError();
}
function stripIPv6ZoneIdentifier(value: string): string {
const zoneIndex = value.indexOf('%');
if (zoneIndex === -1) {
return value;
}
const addressPart = value.slice(0, zoneIndex);
return addressPart.includes(':') ? addressPart : value;
}
function normalizeIPv6(value: string): string {
try {
const hostname = new URL(`http://[${value}]`).hostname;
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
} catch {
return value;
}
}
function parseIPAddress(value: string): PinnedAddress | null {
const trimmed = value.trim();
const unbracketed = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
const unzoned = stripIPv6ZoneIdentifier(unbracketed);
if (isIPv4(unzoned)) {
return {address: unzoned, family: 4};
}
if (isIPv6(unzoned)) {
return {address: normalizeIPv6(unzoned), family: 6};
}
return null;
}
function parseIPv4Octets(address: string): Array<number> | null {
const parts = address.split('.');
if (parts.length !== 4) {
return null;
}
const octets = parts.map((part) => (/^\d{1,3}$/u.test(part) ? Number.parseInt(part, 10) : Number.NaN));
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return null;
}
return octets;
}
function ipv4Value(octets: ReadonlyArray<number>): number {
return ((octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]) >>> 0;
}
function isIPv4InCIDR(value: number, base: number, prefixLength: number): boolean {
const mask = prefixLength === 0 ? 0 : (0xffffffff << (32 - prefixLength)) >>> 0;
return (value & mask) >>> 0 === (base & mask) >>> 0;
}
const IPV4_NON_PUBLIC_RANGES: ReadonlyArray<readonly [base: number, prefixLength: number]> = Object.freeze([
[0x00000000, 8],
[0x0a000000, 8],
[0x64400000, 10],
[0x7f000000, 8],
[0xa9fe0000, 16],
[0xac100000, 12],
[0xc0000000, 24],
[0xc0000200, 24],
[0xc0a80000, 16],
[0xc6120000, 15],
[0xc6336400, 24],
[0xcb007100, 24],
[0xe0000000, 4],
[0xf0000000, 4],
]);
function isPublicIPv4Address(address: string): boolean {
const octets = parseIPv4Octets(address);
if (octets == null) {
return false;
}
const value = ipv4Value(octets);
return !IPV4_NON_PUBLIC_RANGES.some(([base, prefixLength]) => isIPv4InCIDR(value, base, prefixLength));
}
function expandIPv6Groups(address: string): Array<string> {
const halves = address.split('::');
if (halves.length === 2) {
const left = halves[0].length > 0 ? halves[0].split(':') : [];
const right = halves[1].length > 0 ? halves[1].split(':') : [];
const missing = Math.max(IPV6_GROUP_COUNT - left.length - right.length, 0);
return [...left, ...Array<string>(missing).fill('0'), ...right].map((group) => group.padStart(4, '0'));
}
return address.split(':').map((group) => group.padStart(4, '0'));
}
function ipv4FromMappedIPv6(groups: ReadonlyArray<string>): string | null {
const isMapped =
groups[0] === '0000' &&
groups[1] === '0000' &&
groups[2] === '0000' &&
groups[3] === '0000' &&
groups[4] === '0000' &&
groups[5] === 'ffff';
if (!isMapped) {
return null;
}
const high = Number.parseInt(groups[6], 16);
const low = Number.parseInt(groups[7], 16);
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
}
function isPublicIPv6Address(address: string): boolean {
const groups = expandIPv6Groups(address);
if (groups.length !== IPV6_GROUP_COUNT) {
return false;
}
const mapped = ipv4FromMappedIPv6(groups);
if (mapped != null) {
return isPublicIPv4Address(mapped);
}
const first = Number.parseInt(groups[0], 16);
const second = Number.parseInt(groups[1], 16);
const last = Number.parseInt(groups[7], 16);
if (groups.slice(0, 7).every((group) => group === '0000') && (last === 0 || last === 1)) {
return false;
}
if ((first & 0xe000) !== 0x2000) {
return false;
}
if ((first & 0xffc0) === 0xfe80) {
return false;
}
if ((first & 0xfe00) === 0xfc00) {
return false;
}
if ((first & 0xff00) === 0xff00) {
return false;
}
return !(first === 0x2001 && second === 0x0db8);
}
function isPublicPinnedAddress(pinned: PinnedAddress): boolean {
return pinned.family === 4 ? isPublicIPv4Address(pinned.address) : isPublicIPv6Address(pinned.address);
}
function proxiedHostScope(hostname: string): DesktopOriginAddressScope {
const literal = parseIPAddress(hostname);
return literal == null ? DesktopOriginAddressScope.PUBLIC : addressScope(literal);
}
export function requireDesktopHTTPOrigin(value: string): string {
const normalized = normalizeHTTPNetworkOrigin(value);
if (normalized == null) {
throw new DesktopOutboundHTTPInvalidOriginError(value);
}
return new URL(normalized).origin;
}
export function parseDesktopHTTPTarget(value: string): URL | null {
if (Buffer.byteLength(value, 'utf8') > DESKTOP_OUTBOUND_HTTP_MAX_TARGET_URL_BYTES) {
return null;
}
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
if (url.username.length > 0 || url.password.length > 0 || url.hostname.length === 0 || url.port === '0') {
return null;
}
return url;
}
export function parseDesktopRedirectTarget(base: URL, location: string | Array<string> | undefined): URL | null {
if (typeof location !== 'string' || location.length === 0) {
return null;
}
if (Buffer.byteLength(location, 'utf8') > DESKTOP_OUTBOUND_HTTP_MAX_REDIRECT_LOCATION_BYTES) {
return null;
}
let resolved: string;
try {
resolved = new URL(location, base).toString();
} catch {
return null;
}
return parseDesktopHTTPTarget(resolved);
}
function requireDesktopHTTPTarget(value: string, expectedOrigin: string): URL {
const url = parseDesktopHTTPTarget(value);
if (url == null || url.origin !== expectedOrigin) {
throw new DesktopOutboundHTTPInvalidTargetError(value);
}
return url;
}
function normalizeLookupHostname(hostname: string): string {
let value = hostname.trim().toLowerCase();
if (value.startsWith('[') && value.endsWith(']')) {
value = value.slice(1, -1);
}
if (value.length > 1 && value.endsWith('.')) {
value = value.slice(0, -1);
}
return value;
}
function absoluteLookupHostname(hostname: string): string {
if (hostname.includes('.') && !hostname.endsWith('.')) {
return `${hostname}.`;
}
return hostname;
}
export function createPinnedHostLookup(
hostname: string,
pinned: PinnedAddress | ReadonlyArray<PinnedAddress>,
): LookupFunction {
const expected = normalizeLookupHostname(hostname);
const addresses: ReadonlyArray<PinnedAddress> = Array.isArray(pinned) ? pinned : [pinned as PinnedAddress];
const primary = addresses[0];
return (requestedHostname, options, callback) => {
process.nextTick(() => {
if (normalizeLookupHostname(requestedHostname) !== expected) {
callback(new PinnedLookupHostnameMismatchError(), '', undefined);
return;
}
if (options.all === true) {
(callback as unknown as (error: null, addresses: ReadonlyArray<PinnedAddress>) => void)(null, addresses);
return;
}
callback(null, primary.address, primary.family);
});
};
}
function parseContentLengthHeader(value: string | undefined, description: string): number | null {
if (value == null) {
return null;
}
const normalized = value.trim();
if (!/^\d+$/u.test(normalized)) {
throw new InvalidContentLengthError(description);
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isSafeInteger(parsed)) {
throw new InvalidContentLengthError(description);
}
return parsed;
}
export function readMessageContentLength(message: DesktopOutboundHTTPMessage, description: string): number | null {
const raw = message.headers['content-length'];
if (Array.isArray(raw)) {
throw new InvalidContentLengthError(description);
}
return parseContentLengthHeader(raw, description);
}
export async function readBoundedMessage({
declaredBytes,
description,
maxBytes,
maxChunks,
message,
}: BoundedMessageRead): Promise<Buffer> {
if (declaredBytes != null && declaredBytes > maxBytes) {
message.destroy();
throw new BoundedMessageByteLimitError(description, maxBytes);
}
const chunks: Array<Buffer> = [];
let totalBytes = 0;
let chunkCount = 0;
try {
for await (const chunk of message) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
chunkCount += 1;
if (chunkCount > maxChunks) {
throw new BoundedMessageChunkLimitError(description, maxChunks);
}
totalBytes += buffer.byteLength;
if (totalBytes > maxBytes) {
throw new BoundedMessageByteLimitError(description, maxBytes);
}
chunks.push(buffer);
}
} catch (error) {
message.destroy();
throw error;
}
return Buffer.concat(chunks, totalBytes);
}
async function lookupAllAddresses(hostname: string): Promise<ReadonlyArray<string>> {
const records = await dns.promises.lookup(hostname, {all: true, order: 'verbatim'});
return records.map((record) => record.address);
}
function selectPinnedAddresses(candidates: ReadonlyArray<PinnedAddress>): ReadonlyArray<PinnedAddress> {
const unique = candidates.filter(
(candidate, index) =>
candidates.findIndex((other) => other.address === candidate.address && other.family === candidate.family) ===
index,
);
const nonPublic = unique.filter((candidate) => !isPublicPinnedAddress(candidate));
if (nonPublic.length === 0) {
return unique;
}
return nonPublic;
}
function addressScope(address: PinnedAddress): DesktopOriginAddressScope {
return isPublicPinnedAddress(address) ? DesktopOriginAddressScope.PUBLIC : DesktopOriginAddressScope.NON_PUBLIC;
}
function strictestRequirement(
left: DesktopAddressRequirement,
right: DesktopAddressRequirement,
): DesktopAddressRequirement {
if (left === DesktopAddressRequirement.PUBLIC || right === DesktopAddressRequirement.PUBLIC) {
return DesktopAddressRequirement.PUBLIC;
}
return DesktopAddressRequirement.ANY;
}
function requireScope(binding: DesktopOriginAddressBinding, requirement: DesktopAddressRequirement): void {
if (requirement === DesktopAddressRequirement.PUBLIC && binding.scope !== DesktopOriginAddressScope.PUBLIC) {
throw new DesktopOutboundHTTPPublicAddressRequiredError(binding.origin);
}
}
function requireStableScope(
previous: DesktopOriginAddressBinding | undefined,
next: DesktopOriginAddressBinding,
): void {
if (previous != null && previous.scope !== next.scope) {
throw new DesktopOutboundHTTPMixedAddressScopeError(next.origin);
}
}
function isStaleKeepAliveSocketError(error: unknown): boolean {
if (error == null || typeof error !== 'object') {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
return code != null && DESKTOP_OUTBOUND_HTTP_STALE_SOCKET_CODES.has(code);
}
function isReplayableRequest(request: DesktopOutboundHTTPRequest): boolean {
if (!DESKTOP_OUTBOUND_HTTP_IDEMPOTENT_METHODS.has(request.method.toUpperCase())) {
return false;
}
return request.body == null || request.body instanceof Uint8Array;
}
export function isDesktopHostResolutionFailure(error: unknown): boolean {
if (error == null || typeof error !== 'object') {
return false;
}
if (error instanceof DesktopOutboundHTTPResolutionTimeoutError) {
return true;
}
return (error as NodeJS.ErrnoException).syscall === 'getaddrinfo';
}
function isUnreachableAddressError(error: unknown): boolean {
if (error == null || typeof error !== 'object') {
return false;
}
const code = (error as NodeJS.ErrnoException).code;
if (code != null && DESKTOP_OUTBOUND_HTTP_UNREACHABLE_ADDRESS_CODES.has(code)) {
return true;
}
const causes = (error as AggregateError).errors;
return Array.isArray(causes) && causes.some((cause) => isUnreachableAddressError(cause));
}
function requestHeaders(headers: Readonly<Record<string, string>> | null): Record<string, string> {
const normalized: Record<string, string> = {};
if (headers == null) {
return normalized;
}
for (const [name, value] of Object.entries(headers)) {
if (name.toLowerCase() === 'host') {
continue;
}
normalized[name] = value;
}
return normalized;
}
function createRequestBodyLimit(maximumBytes: number): Transform {
let totalBytes = 0;
return new Transform({
transform(chunk: Buffer, _encoding, callback) {
totalBytes += chunk.byteLength;
if (totalBytes > maximumBytes) {
callback(new DesktopOutboundHTTPRequestBodyLimitError(maximumBytes));
return;
}
callback(null, chunk);
},
});
}
interface DesktopProxiedSend {
readonly body: Uint8Array | Readable | null;
readonly headers: Readonly<Record<string, string>>;
readonly method: string;
readonly release: () => void;
readonly signal: AbortSignal | null;
readonly target: URL;
readonly timeout: () => Error;
readonly timeoutMs: number;
}
export class DesktopOutboundHTTP {
private readonly httpAgent = new http.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS});
private readonly httpsAgent: https.Agent;
private readonly originRequestHttpAgent = new http.Agent({
keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
});
private readonly originRequestHttpsAgent: https.Agent;
private readonly bindings = new Map<string, DesktopOriginAddressBinding>();
private readonly pendingBindings = new Map<string, DesktopPendingOriginBinding>();
private readonly registeredRequirements = new Map<string, DesktopAddressRequirement>();
private readonly originRequestsInFlightByService = new Map<string, number>();
private readonly resolveHostAddresses: DesktopHostAddressResolver;
private readonly resolveProxy: DesktopProxyResolver;
private readonly sendThroughSession: DesktopSessionHTTPSender;
private inFlight = 0;
private originRequestsInFlight = 0;
private activeResolutions = 0;
private acceptingRequests = true;
public constructor(options: DesktopOutboundHTTPOptions = {}) {
this.resolveHostAddresses = options.resolveHostAddresses ?? lookupAllAddresses;
this.resolveProxy = options.resolveProxy ?? resolveDesktopSessionProxy;
this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession;
const certificates = options.trustedCertificates ?? resolveDesktopTrustedCertificates();
const trust = certificates.length > 0 ? {ca: [...certificates]} : {};
this.httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS, ...trust});
this.originRequestHttpsAgent = new https.Agent({
keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
...trust,
});
}
public async get(request: DesktopOutboundGETRequest): Promise<DesktopOutboundHTTPMessage> {
if (this.inFlight >= DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT) {
throw new DesktopOutboundHTTPCapacityError();
}
this.inFlight += 1;
let released = false;
const release = (): void => {
if (released) {
return;
}
released = true;
this.inFlight -= 1;
};
try {
const requirement = this.registeredRequirements.get(request.url.origin) ?? DesktopAddressRequirement.PUBLIC;
if (requirement === DesktopAddressRequirement.PUBLIC && request.url.protocol !== 'https:') {
throw blocked(DesktopOutboundBlockReason.INSECURE_TRANSPORT, request.context, request.url.hostname);
}
if (!(await this.routesDirect(request.url))) {
if (
requirement === DesktopAddressRequirement.PUBLIC &&
proxiedHostScope(request.url.hostname) !== DesktopOriginAddressScope.PUBLIC
) {
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, request.context, request.url.hostname);
}
return await this.issueProxied(request, release);
}
const pinned = await this.pinAddress(request.url.hostname, requirement, request.context);
return await this.issue(request, pinned, release);
} catch (error) {
release();
throw error;
}
}
public async registerAnchoredOrigins({
anchorOrigin,
origins,
unresolvedAnchorRequirement = null,
}: DesktopAnchoredOriginRegistration): Promise<DesktopAddressRequirement> {
this.requireAdmission();
const canonicalAnchor = requireDesktopHTTPOrigin(anchorOrigin);
const requirement = await this.anchorRequirement(canonicalAnchor, unresolvedAnchorRequirement);
const canonicalOrigins = new Set<string>([canonicalAnchor]);
for (const origin of origins) {
canonicalOrigins.add(requireDesktopHTTPOrigin(origin));
}
let additions = 0;
for (const origin of canonicalOrigins) {
if (!this.registeredRequirements.has(origin)) {
additions += 1;
}
const binding = this.bindings.get(origin);
if (binding != null) {
requireScope(binding, requirement);
}
}
if (this.registeredRequirements.size + additions > DESKTOP_OUTBOUND_HTTP_MAX_ORIGINS) {
throw new DesktopOutboundHTTPCapacityError('registered origin');
}
for (const origin of canonicalOrigins) {
const current = this.registeredRequirements.get(origin);
const next = current == null ? requirement : strictestRequirement(current, requirement);
this.registeredRequirements.set(origin, next);
const pending = this.pendingBindings.get(origin);
if (pending != null) {
pending.requirement = strictestRequirement(pending.requirement, next);
}
}
return requirement;
}
private async anchorRequirement(
canonicalAnchor: string,
unresolvedAnchorRequirement: DesktopAddressRequirement | null,
): Promise<DesktopAddressRequirement> {
if (!(await this.routesDirect(new URL(canonicalAnchor)))) {
return proxiedHostScope(new URL(canonicalAnchor).hostname) === DesktopOriginAddressScope.PUBLIC
? DesktopAddressRequirement.PUBLIC
: DesktopAddressRequirement.ANY;
}
let anchor: DesktopOriginAddressBinding;
try {
anchor = await this.ensureBinding(canonicalAnchor, DesktopAddressRequirement.ANY);
} catch (error) {
if (unresolvedAnchorRequirement === null || !isDesktopHostResolutionFailure(error)) {
throw error;
}
return unresolvedAnchorRequirement;
}
return anchor.scope === DesktopOriginAddressScope.PUBLIC
? DesktopAddressRequirement.PUBLIC
: DesktopAddressRequirement.ANY;
}
public isRegisteredOrigin(origin: string): boolean {
return this.registeredRequirements.has(requireDesktopHTTPOrigin(origin));
}
public registeredCleartextOrigins(): Array<string> {
const origins: Array<string> = [];
for (const [origin, requirement] of this.registeredRequirements) {
if (requirement === DesktopAddressRequirement.ANY && origin.startsWith('http://')) {
origins.push(origin);
}
}
return origins.sort();
}
public requireRegisteredOrigins(origins: ReadonlyArray<string>): void {
for (const origin of origins) {
const canonical = requireDesktopHTTPOrigin(origin);
if (!this.registeredRequirements.has(canonical)) {
throw new DesktopOutboundHTTPOriginNotRegisteredError(canonical);
}
}
}
public async request(request: DesktopOutboundHTTPRequest): Promise<DesktopOutboundHTTPMessage> {
this.requireAdmission();
const origin = requireDesktopHTTPOrigin(request.expectedOrigin);
const target = requireDesktopHTTPTarget(request.url, origin);
const maximumRequestBodyBytes = request.maximumRequestBodyBytes ?? DESKTOP_OUTBOUND_HTTP_MAX_REQUEST_BODY_BYTES;
if (request.body instanceof Uint8Array && request.body.byteLength > maximumRequestBodyBytes) {
throw new DesktopOutboundHTTPRequestBodyLimitError(maximumRequestBodyBytes);
}
const requirement = this.requirementFor(origin, request.originTrust);
if (!(await this.routesDirect(target))) {
const scope = proxiedHostScope(target.hostname);
if (requirement === DesktopAddressRequirement.PUBLIC && scope !== DesktopOriginAddressScope.PUBLIC) {
throw new DesktopOutboundHTTPPublicAddressRequiredError(origin);
}
if (scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') {
throw new DesktopOutboundHTTPInsecureTransportError(origin);
}
this.requireAdmission();
return await this.issueProxiedOriginRequest(request, target);
}
const binding = await this.ensureBinding(origin, requirement);
if (binding.scope === DesktopOriginAddressScope.PUBLIC && target.protocol !== 'https:') {
throw new DesktopOutboundHTTPInsecureTransportError(origin);
}
this.requireAdmission();
return await this.issueOriginRequest(request, target, binding);
}
public async requireCleartextTransportAddress(origin: string): Promise<string> {
this.requireAdmission();
const canonical = requireDesktopHTTPOrigin(origin);
const binding = await this.ensureBinding(
canonical,
this.registeredRequirements.get(canonical) ?? DesktopAddressRequirement.ANY,
);
if (binding.scope === DesktopOriginAddressScope.PUBLIC) {
throw new DesktopOutboundHTTPInsecureTransportError(canonical);
}
return (binding.addresses.find((address) => address.family === 4) ?? binding.address).address;
}
public cleanup(): void {
if (!this.acceptingRequests) {
return;
}
this.acceptingRequests = false;
this.httpAgent.destroy();
this.httpsAgent.destroy();
this.originRequestHttpAgent.destroy();
this.originRequestHttpsAgent.destroy();
this.bindings.clear();
this.registeredRequirements.clear();
this.originRequestsInFlightByService.clear();
}
private async routesDirect(url: URL): Promise<boolean> {
return isDirectProxyRoute(await this.resolveProxy(url.href));
}
private issueProxied(request: DesktopOutboundGETRequest, release: () => void): Promise<DesktopOutboundHTTPMessage> {
return this.sendProxied({
body: null,
headers: {},
method: 'GET',
signal: null,
target: request.url,
timeout: () => new DesktopOutboundHTTPTimeoutError(),
timeoutMs: request.timeoutMs,
release,
}).catch((error: unknown) => {
if (error instanceof DesktopOutboundHTTPTimeoutError) {
throw error;
}
logger.warn('Outbound request failed', {context: request.context, hostname: request.url.hostname, error});
throw new DesktopOutboundHTTPTransportError();
});
}
private issueProxiedOriginRequest(
request: DesktopOutboundHTTPRequest,
target: URL,
): Promise<DesktopOutboundHTTPMessage> {
this.acquireSlot(request.serviceName);
let released = false;
const release = (): void => {
if (released) {
return;
}
released = true;
this.releaseSlot(request.serviceName);
};
return this.sendProxied({
body: this.proxiedRequestBody(request),
headers: requestHeaders(request.headers),
method: request.method,
signal: request.signal,
target,
timeout: () => new DesktopOutboundHTTPRequestTimeoutError(target.toString(), request.timeoutMs),
timeoutMs: request.timeoutMs,
release,
}).catch((error: unknown) => {
if (request.signal?.aborted === true) {
throw new DesktopOutboundHTTPRequestAbortedError(target.toString());
}
throw error;
});
}
private async sendProxied({
body,
headers,
method,
signal,
target,
timeout,
timeoutMs,
release,
}: DesktopProxiedSend): Promise<DesktopOutboundHTTPMessage> {
const deadline = new AbortController();
const timer = setTimeout(() => deadline.abort(timeout()), timeoutMs);
timer.unref();
const settle = (): void => {
clearTimeout(timer);
release();
};
try {
const response = await this.sendThroughSession({
body,
headers,
method,
signal: signal == null ? deadline.signal : AbortSignal.any([deadline.signal, signal]),
url: target,
});
response.message.on('end', settle);
response.message.on('close', settle);
response.message.on('error', settle);
return {...response, url: target};
} catch (error) {
settle();
throw error;
}
}
private proxiedRequestBody(request: DesktopOutboundHTTPRequest): Uint8Array | Readable | null {
const body = request.body;
if (body == null || body instanceof Uint8Array) {
return body;
}
const limit = createRequestBodyLimit(
request.maximumRequestBodyBytes ?? DESKTOP_OUTBOUND_HTTP_MAX_REQUEST_BODY_BYTES,
);
const source = Readable.fromWeb(body as unknown as NodeReadableStream<Uint8Array>);
source.on('error', (error: Error) => limit.destroy(error));
return source.pipe(limit);
}
private async pinAddress(
hostname: string,
requirement: DesktopAddressRequirement,
context: string,
): Promise<ReadonlyArray<PinnedAddress>> {
const literal = parseIPAddress(hostname);
if (literal != null) {
if (requirement === DesktopAddressRequirement.PUBLIC && !isPublicPinnedAddress(literal)) {
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
}
return [literal];
}
let addresses: ReadonlyArray<string>;
try {
addresses = await this.resolveHost(hostname);
} catch (error) {
const reason =
error instanceof DesktopOutboundHTTPTimeoutError
? DesktopOutboundBlockReason.RESOLUTION_TIMEOUT
: DesktopOutboundBlockReason.RESOLUTION_FAILED;
throw blocked(reason, context, hostname);
}
const candidates = addresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
const pinned = selectPinnedAddresses(candidates);
if (pinned.length === 0) {
throw blocked(DesktopOutboundBlockReason.NO_USABLE_ADDRESS, context, hostname);
}
if (requirement === DesktopAddressRequirement.PUBLIC && pinned.some((address) => !isPublicPinnedAddress(address))) {
throw blocked(DesktopOutboundBlockReason.NON_PUBLIC_ADDRESS, context, hostname);
}
return pinned;
}
private async resolveHost(hostname: string): Promise<ReadonlyArray<string>> {
return await Promise.race([
this.resolveHostAddresses(hostname),
new Promise<never>((_resolve, reject) => {
const timer = setTimeout(
() => reject(new DesktopOutboundHTTPTimeoutError()),
DESKTOP_OUTBOUND_HTTP_RESOLUTION_TIMEOUT_MS,
);
timer.unref();
}),
]);
}
private issue(
request: DesktopOutboundGETRequest,
pinned: ReadonlyArray<PinnedAddress>,
release: () => void,
): Promise<DesktopOutboundHTTPMessage> {
return new Promise<DesktopOutboundHTTPMessage>((resolve, reject) => {
const secure = request.url.protocol === 'https:';
const transport = secure ? https : http;
const clientRequest = transport.request(request.url, {
agent: secure ? this.httpsAgent : this.httpAgent,
lookup: createPinnedHostLookup(request.url.hostname, pinned),
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
method: 'GET',
});
const deadline = setTimeout(() => {
clientRequest.destroy(new DesktopOutboundHTTPTimeoutError());
}, request.timeoutMs);
deadline.unref();
let settled = false;
const settle = (): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(deadline);
release();
};
clientRequest.on('error', (error) => {
settle();
if (error instanceof DesktopOutboundHTTPTimeoutError) {
reject(error);
return;
}
logger.warn('Outbound request failed', {context: request.context, hostname: request.url.hostname, error});
reject(new DesktopOutboundHTTPTransportError());
});
clientRequest.on('response', (message) => {
message.on('end', settle);
message.on('close', settle);
message.on('error', settle);
resolve({
headers: message.headers,
message,
status: message.statusCode ?? 0,
statusText: message.statusMessage ?? '',
url: request.url,
});
});
clientRequest.end();
});
}
private requirementFor(origin: string, trust: DesktopOriginTrust): DesktopAddressRequirement {
const registered = this.registeredRequirements.get(origin);
if (registered != null) {
return registered;
}
if (trust === DesktopOriginTrust.REGISTERED) {
throw new DesktopOutboundHTTPOriginNotRegisteredError(origin);
}
return DesktopAddressRequirement.ANY;
}
private async ensureBinding(
origin: string,
requirement: DesktopAddressRequirement,
): Promise<DesktopOriginAddressBinding> {
this.requireAdmission();
const existing = this.bindings.get(origin);
if (existing != null && !existing.unreachable) {
requireScope(existing, requirement);
return existing;
}
const pending = this.pendingBindings.get(origin);
if (pending != null) {
pending.requirement = strictestRequirement(pending.requirement, requirement);
const binding = await pending.operation;
requireScope(binding, pending.requirement);
return binding;
}
if (this.pendingBindings.size >= DESKTOP_OUTBOUND_HTTP_MAX_PENDING_RESOLUTIONS) {
throw new DesktopOutboundHTTPCapacityError('pending resolution');
}
if (existing == null && this.bindings.size + this.pendingBindings.size >= DESKTOP_OUTBOUND_HTTP_MAX_ORIGINS) {
throw new DesktopOutboundHTTPCapacityError('origin');
}
const record: DesktopPendingOriginBinding = {
operation: this.resolveBinding(origin).then((binding) => {
this.requireAdmission();
requireStableScope(existing, binding);
requireScope(binding, record.requirement);
this.bindings.set(origin, binding);
return binding;
}),
requirement,
};
this.pendingBindings.set(origin, record);
try {
const binding = await record.operation;
requireScope(binding, record.requirement);
return binding;
} finally {
if (this.pendingBindings.get(origin) === record) {
this.pendingBindings.delete(origin);
}
}
}
private async resolveBinding(origin: string): Promise<DesktopOriginAddressBinding> {
const hostname = new URL(origin).hostname;
const literal = parseIPAddress(hostname);
if (literal != null) {
return {address: literal, addresses: [literal], origin, scope: addressScope(literal), unreachable: false};
}
const rawAddresses = await this.resolveOriginHost(absoluteLookupHostname(hostname), origin);
const candidates = rawAddresses.map((address) => parseIPAddress(address)).filter((value) => value != null);
const addresses = selectPinnedAddresses(candidates);
const primary = addresses[0];
if (primary == null) {
throw new DesktopOutboundHTTPEmptyResolutionError(origin);
}
return {address: primary, addresses, origin, scope: addressScope(primary), unreachable: false};
}
private markBindingUnreachable(binding: DesktopOriginAddressBinding): void {
if (this.bindings.get(binding.origin) !== binding) {
return;
}
this.bindings.set(binding.origin, {...binding, unreachable: true});
}
private async resolveOriginHost(hostname: string, origin: string): Promise<ReadonlyArray<string>> {
if (this.activeResolutions >= DESKTOP_OUTBOUND_HTTP_MAX_PENDING_RESOLUTIONS) {
throw new DesktopOutboundHTTPCapacityError('pending resolution');
}
this.activeResolutions += 1;
try {
return await Promise.race([
this.resolveHostAddresses(hostname),
new Promise<never>((_resolve, reject) => {
const timeout = setTimeout(
() => reject(new DesktopOutboundHTTPResolutionTimeoutError(origin)),
DESKTOP_OUTBOUND_HTTP_RESOLUTION_TIMEOUT_MS,
);
timeout.unref();
}),
]);
} finally {
this.activeResolutions -= 1;
}
}
private acquireSlot(serviceName: string): void {
if (this.originRequestsInFlight >= DESKTOP_OUTBOUND_HTTP_MAX_ORIGIN_REQUESTS_IN_FLIGHT) {
throw new DesktopOutboundHTTPCapacityError('in-flight request');
}
const perService = this.originRequestsInFlightByService.get(serviceName) ?? 0;
if (perService >= DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE) {
throw new DesktopOutboundHTTPCapacityError(`in-flight request for ${serviceName}`);
}
this.originRequestsInFlight += 1;
this.originRequestsInFlightByService.set(serviceName, perService + 1);
}
private releaseSlot(serviceName: string): void {
this.originRequestsInFlight -= 1;
const perService = (this.originRequestsInFlightByService.get(serviceName) ?? 1) - 1;
if (perService <= 0) {
this.originRequestsInFlightByService.delete(serviceName);
} else {
this.originRequestsInFlightByService.set(serviceName, perService);
}
}
private issueOriginRequest(
request: DesktopOutboundHTTPRequest,
target: URL,
binding: DesktopOriginAddressBinding,
): Promise<DesktopOutboundHTTPMessage> {
this.acquireSlot(request.serviceName);
let released = false;
const release = (): void => {
if (released) {
return;
}
released = true;
this.releaseSlot(request.serviceName);
};
return new Promise<DesktopOutboundHTTPMessage>((resolve, reject) => {
let attemptsRemaining = isReplayableRequest(request) ? DESKTOP_OUTBOUND_HTTP_STALE_SOCKET_ATTEMPTS : 0;
let active: http.ClientRequest | null = null;
let terminated = false;
const timeout = setTimeout(() => {
terminated = true;
active?.destroy(new DesktopOutboundHTTPRequestTimeoutError(target.toString(), request.timeoutMs));
}, request.timeoutMs);
timeout.unref();
const onAbort = (): void => {
terminated = true;
active?.destroy(new DesktopOutboundHTTPRequestAbortedError(target.toString()));
};
request.signal?.addEventListener('abort', onAbort, {once: true});
const settle = (): void => {
clearTimeout(timeout);
request.signal?.removeEventListener('abort', onAbort);
release();
};
const destroyActive = (error: Error): void => {
active?.destroy(error);
};
if (request.signal?.aborted === true) {
settle();
reject(new DesktopOutboundHTTPRequestAbortedError(target.toString()));
return;
}
const attempt = (): void => {
const transport = target.protocol === 'https:' ? https : http;
const clientRequest = transport.request(target, {
agent: target.protocol === 'https:' ? this.originRequestHttpsAgent : this.originRequestHttpAgent,
headers: requestHeaders(request.headers),
lookup: createPinnedHostLookup(target.hostname, binding.addresses),
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
method: request.method,
});
active = clientRequest;
clientRequest.on('error', (error) => {
if (
!terminated &&
attemptsRemaining > 0 &&
clientRequest.reusedSocket === true &&
isStaleKeepAliveSocketError(error)
) {
attemptsRemaining -= 1;
attempt();
return;
}
settle();
if (isUnreachableAddressError(error)) {
this.markBindingUnreachable(binding);
}
reject(error);
});
clientRequest.on('response', (message) => {
attemptsRemaining = 0;
message.on('end', settle);
message.on('close', settle);
message.on('error', settle);
resolve({
headers: message.headers,
message,
status: message.statusCode ?? 0,
statusText: message.statusMessage ?? '',
url: target,
});
});
this.sendRequestBody(clientRequest, request);
};
try {
attempt();
} catch (error) {
destroyActive(error instanceof Error ? error : new Error(String(error)));
settle();
reject(error);
}
});
}
private sendRequestBody(clientRequest: http.ClientRequest, request: DesktopOutboundHTTPRequest): void {
const body = request.body;
if (body == null) {
clientRequest.end();
return;
}
if (body instanceof Uint8Array) {
clientRequest.end(Buffer.from(body));
return;
}
if (!clientRequest.hasHeader('content-length') && !clientRequest.hasHeader('transfer-encoding')) {
clientRequest.setHeader('Transfer-Encoding', 'chunked');
}
const source = Readable.fromWeb(body as unknown as NodeReadableStream<Uint8Array>);
const limit = createRequestBodyLimit(
request.maximumRequestBodyBytes ?? DESKTOP_OUTBOUND_HTTP_MAX_REQUEST_BODY_BYTES,
);
source.on('error', (error: Error) => {
limit.destroy(error);
clientRequest.destroy(error);
});
limit.on('error', (error: Error) => {
source.destroy(error);
clientRequest.destroy(error);
});
clientRequest.once('close', () => {
source.destroy();
limit.destroy();
});
source.pipe(limit).pipe(clientRequest);
}
private requireAdmission(): void {
if (!this.acceptingRequests) {
throw new DesktopOutboundHTTPClosedError();
}
}
}
let sharedOutboundHTTP: DesktopOutboundHTTP | null = null;
export function getDesktopOutboundHTTP(): DesktopOutboundHTTP {
sharedOutboundHTTP ??= new DesktopOutboundHTTP();
return sharedOutboundHTTP;
}
export function cleanupDesktopOutboundHTTP(): void {
sharedOutboundHTTP?.cleanup();
sharedOutboundHTTP = null;
}