mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
102 lines
3.8 KiB
TypeScript
102 lines
3.8 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {isIPv4} from 'node:net';
|
|
import {getIpNetworkKey} from '@fluxer/ip_utils/src/IpAddress';
|
|
|
|
interface ValidateOutboundEndpointOptions {
|
|
name: string;
|
|
allowHttp: boolean;
|
|
allowLocalhost: boolean;
|
|
allowPrivateIpLiterals: boolean;
|
|
}
|
|
|
|
export function validateOutboundEndpointUrl(rawEndpoint: string, options: ValidateOutboundEndpointOptions): URL {
|
|
let endpointUrl: URL;
|
|
try {
|
|
endpointUrl = new URL(rawEndpoint);
|
|
} catch {
|
|
throw new Error(`${options.name} must be a valid URL`);
|
|
}
|
|
if (endpointUrl.protocol !== 'http:' && endpointUrl.protocol !== 'https:') {
|
|
throw new Error(`${options.name} must use http or https`);
|
|
}
|
|
if (endpointUrl.protocol === 'http:' && !options.allowHttp) {
|
|
throw new Error(`${options.name} must use https`);
|
|
}
|
|
if (endpointUrl.username || endpointUrl.password) {
|
|
throw new Error(`${options.name} must not include URL credentials`);
|
|
}
|
|
if (endpointUrl.search || endpointUrl.hash) {
|
|
throw new Error(`${options.name} must not include query string or fragment`);
|
|
}
|
|
const hostname = endpointUrl.hostname.toLowerCase();
|
|
if (!hostname) {
|
|
throw new Error(`${options.name} must include a hostname`);
|
|
}
|
|
if (!options.allowLocalhost && isLocalhostHostname(hostname)) {
|
|
throw new Error(`${options.name} cannot use localhost`);
|
|
}
|
|
if (!options.allowPrivateIpLiterals && isPrivateOrSpecialIpLiteral(hostname)) {
|
|
throw new Error(`${options.name} cannot use private or special IP literals`);
|
|
}
|
|
return endpointUrl;
|
|
}
|
|
|
|
function normalizeEndpointOrigin(endpointUrl: URL): string {
|
|
const pathname = endpointUrl.pathname.endsWith('/') ? endpointUrl.pathname.slice(0, -1) : endpointUrl.pathname;
|
|
const normalisedPath = pathname === '/' ? '' : pathname;
|
|
return `${endpointUrl.protocol}//${endpointUrl.host}${normalisedPath}`;
|
|
}
|
|
|
|
export function buildEndpointUrl(endpointUrl: URL, path: string): string {
|
|
const trimmedPath = path.trim();
|
|
if (!trimmedPath) {
|
|
return normalizeEndpointOrigin(endpointUrl);
|
|
}
|
|
const lowercasePath = trimmedPath.toLowerCase();
|
|
if (lowercasePath.startsWith('http://') || lowercasePath.startsWith('https://') || trimmedPath.startsWith('//')) {
|
|
throw new Error('Outbound path must be relative');
|
|
}
|
|
const prefixedPath = trimmedPath.startsWith('/') ? trimmedPath : `/${trimmedPath}`;
|
|
return `${normalizeEndpointOrigin(endpointUrl)}${prefixedPath}`;
|
|
}
|
|
|
|
function isLocalhostHostname(hostname: string): boolean {
|
|
return hostname === 'localhost' || hostname.endsWith('.localhost');
|
|
}
|
|
|
|
function isPrivateOrSpecialIpLiteral(hostname: string): boolean {
|
|
const address = getIpNetworkKey(hostname, {ipv4PrefixLength: 'exact', ipv6PrefixLength: 'exact'});
|
|
if (address === null) {
|
|
return false;
|
|
}
|
|
if (isIPv4(address)) {
|
|
return isPrivateOrSpecialIPv4(address);
|
|
}
|
|
return isPrivateOrSpecialIPv6(address);
|
|
}
|
|
|
|
function isPrivateOrSpecialIPv4(hostname: string): boolean {
|
|
const octets = hostname.split('.').map((part) => Number(part));
|
|
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
return true;
|
|
}
|
|
const [first, second] = octets;
|
|
if (first === 0 || first === 10 || first === 127) return true;
|
|
if (first === 169 && second === 254) return true;
|
|
if (first === 172 && second >= 16 && second <= 31) return true;
|
|
if (first === 192 && second === 168) return true;
|
|
if (first === 100 && second >= 64 && second <= 127) return true;
|
|
if (first === 198 && (second === 18 || second === 19)) return true;
|
|
if (first >= 224) return true;
|
|
return false;
|
|
}
|
|
|
|
function isPrivateOrSpecialIPv6(address: string): boolean {
|
|
if (address === '::' || address === '::1') {
|
|
return true;
|
|
}
|
|
const firstGroup = Number.parseInt(address.split(':', 1)[0] || '0', 16);
|
|
return (firstGroup & 0xffc0) === 0xfe80 || (firstGroup & 0xfe00) === 0xfc00 || (firstGroup & 0xff00) === 0xff00;
|
|
}
|