import { isIP } from 'node:net'; import { asUpstreamError, UpstreamError } from './upstream-error.js'; export interface ResolvedAddress { readonly address: string; readonly family: 4 | 6; } export interface TransportResponse { readonly status: number; readonly headers: Readonly>; readonly body: string; } export interface PinnedRequest { readonly url: string; readonly method: 'GET' | 'POST'; readonly headers: Readonly>; readonly body?: string; } export interface SafeTransportOptions { readonly resolve: (hostname: string) => Promise; readonly request: ( request: PinnedRequest, addresses: readonly ResolvedAddress[], ) => Promise; } export class TransportError extends UpstreamError { constructor(override readonly code: 'UNSAFE_ORIGIN' | 'UNSAFE_RESOLUTION' | 'REDIRECT_REJECTED') { super(code); this.name = 'TransportError'; } } const isPrivateV4 = (address: string): boolean => { const parts = address.split('.').map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; const [a, b, c] = parts; if (a === undefined || b === undefined || c === undefined) return false; return ( a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || (a === 192 && b === 0 && c === 0) ); }; const isAllowedAddress = ({ address, family }: ResolvedAddress): boolean => { if (family === 4) return isPrivateV4(address); const normalized = address.toLowerCase(); return normalized.startsWith('fc') || normalized.startsWith('fd'); }; const origin = (raw: string): URL => { let parsed: URL; try { parsed = new URL(raw); } catch { throw new TransportError('UNSAFE_ORIGIN'); } if ( (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || parsed.username || parsed.password || parsed.hash ) throw new TransportError('UNSAFE_ORIGIN'); if (!parsed.pathname.startsWith('/')) throw new TransportError('UNSAFE_ORIGIN'); return parsed; }; export class SafeInstanceTransport { constructor(private readonly options: SafeTransportOptions) {} async get( raw: string, headers: Readonly> = {}, ): Promise { return this.send({ url: raw, method: 'GET', headers }); } async post( raw: string, headers: Readonly>, body: string, ): Promise { return this.send({ url: raw, method: 'POST', headers, body }); } private async send(request: PinnedRequest): Promise { const parsed = origin(request.url); const dialHost = parsed.hostname.replace(/^\[|\]$/g, ''); const literalFamily = isIP(dialHost); let addresses: readonly ResolvedAddress[]; try { addresses = literalFamily ? [{ address: dialHost, family: literalFamily as 4 | 6 }] : await this.options.resolve(dialHost); } catch (error) { throw asUpstreamError(error); } if (addresses.length === 0 || addresses.some((entry) => !isAllowedAddress(entry))) throw new TransportError('UNSAFE_RESOLUTION'); let response: TransportResponse; try { response = await this.options.request({ ...request, url: parsed.toString() }, addresses); } catch (error) { throw asUpstreamError(error); } if (response.status >= 300 && response.status < 400) throw new TransportError('REDIRECT_REJECTED'); return response; } }