feat(api): complete phase 2.4 control plane
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
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.search ||
|
||||
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): 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user