Files
multi-simadmin/apps/api/src/infrastructure/transport/safe-instance-transport.ts
T

108 lines
3.6 KiB
TypeScript

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<Record<string, string | undefined>>;
readonly body: string;
}
export interface PinnedRequest {
readonly url: string;
readonly method: 'GET' | 'POST';
readonly headers: Readonly<Record<string, string>>;
readonly body?: string;
}
export interface SafeTransportOptions {
readonly resolve: (hostname: string) => Promise<readonly ResolvedAddress[]>;
readonly request: (
request: PinnedRequest,
addresses: readonly ResolvedAddress[],
) => Promise<TransportResponse>;
}
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<Record<string, string>> = {},
): Promise<TransportResponse> {
return this.send({ url: raw, method: 'GET', headers });
}
async post(
raw: string,
headers: Readonly<Record<string, string>>,
body: string,
): Promise<TransportResponse> {
return this.send({ url: raw, method: 'POST', headers, body });
}
private async send(request: PinnedRequest): Promise<TransportResponse> {
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;
}
}