108 lines
3.6 KiB
TypeScript
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;
|
|
}
|
|
}
|