Files
fluxer/packages/hono/src/security/OutboundEndpoint.ts
T

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;
}