webssh2-server
Version:
A Websocket to SSH2 gateway using xterm.js, socket.io, ssh2
260 lines (259 loc) • 8.26 kB
JavaScript
// app/ssh/hostname-resolver.ts
// Async hostname resolution service for subnet validation
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { createNamespacedDebug } from '../logger.js';
const debug = createNamespacedDebug('ssh:hostname-resolver');
/**
* Resolve hostname to IP addresses
* Performs async DNS lookup
*/
export const resolveHostname = async (hostname) => {
try {
debug(`Resolving hostname: ${hostname}`);
const ipVersion = isIP(hostname);
if (ipVersion === 4 || ipVersion === 6) {
debug(`${hostname} is already an IP address`);
return {
ok: true,
value: {
hostname,
addresses: [hostname]
}
};
}
// Perform DNS lookup
const lookupResult = await lookup(hostname, { all: true });
const entries = Array.isArray(lookupResult)
? lookupResult
: [lookupResult];
const addresses = entries
.map((entry) => {
if (typeof entry === 'string') {
return entry;
}
return entry.address;
})
.filter((address) => typeof address === 'string' && address !== '');
if (addresses.length === 0) {
return {
ok: false,
error: new Error(`No DNS records found for ${hostname}`)
};
}
debug(`Resolved ${hostname} to ${addresses.join(', ')}`);
return {
ok: true,
value: {
hostname,
addresses
}
};
}
catch (error) {
const message = error instanceof Error
? error.message
: 'Failed to resolve hostname';
debug(`Failed to resolve ${hostname}: ${message}`);
return {
ok: false,
error: new Error(`DNS resolution failed for ${hostname}: ${message}`)
};
}
};
/**
* Check if an IPv4 address matches a subnet in CIDR notation
*/
const isIpv4InCidr = (ip, subnet) => {
const [subnetBase, maskStr] = subnet.split('/');
if (subnetBase === undefined || maskStr === undefined) {
return false;
}
const mask = Number.parseInt(maskStr, 10);
if (Number.isNaN(mask) || mask < 0 || mask > 32) {
return false;
}
// Convert IP addresses to 32-bit integers
const ipToInt = (addr) => {
const parts = addr.split('.');
if (parts.length !== 4) {
return 0;
}
return parts.reduce((acc, part, i) => {
const num = Number.parseInt(part, 10);
if (Number.isNaN(num) || num < 0 || num > 255) {
return 0;
}
return acc + (num << (8 * (3 - i)));
}, 0);
};
const ipInt = ipToInt(ip);
const subnetInt = ipToInt(subnetBase);
const maskBits = (0xFFFFFFFF << (32 - mask)) >>> 0;
return (ipInt & maskBits) === (subnetInt & maskBits);
};
/**
* Check if an IPv6 address matches a subnet in CIDR notation
*/
const isIpv6InCidr = (ip, subnet) => {
const parsed = parseIpv6Cidr(subnet);
if (parsed === null) {
return false;
}
const normalizedIp = normalizeIpv6Address(ip.toLowerCase());
const normalizedSubnet = normalizeIpv6Address(parsed.base.toLowerCase());
return ipv6MatchesMask(normalizedIp, normalizedSubnet, parsed.mask);
};
const parseIpv6Cidr = (subnet) => {
const [subnetBase, maskStr] = subnet.split('/');
if (subnetBase === undefined || maskStr === undefined) {
return null;
}
const mask = Number.parseInt(maskStr, 10);
if (Number.isNaN(mask) || mask < 0 || mask > 128) {
return null;
}
return { base: subnetBase, mask };
};
const normalizeIpv6Address = (addr) => {
if (addr.includes('::')) {
const parts = addr.split('::');
const left = parts[0]?.split(':').filter(segment => segment !== '') ?? [];
const right = parts[1]?.split(':').filter(segment => segment !== '') ?? [];
const missing = Math.max(0, 8 - left.length - right.length);
const middle = Array.from({ length: missing }, () => '0');
const expanded = [...left, ...middle, ...right];
return expanded.map(segment => segment.padStart(4, '0')).join(':');
}
return addr.split(':').map(segment => segment.padStart(4, '0')).join(':');
};
const ipv6MatchesMask = (ip, subnet, mask) => {
let bitsChecked = 0;
const subnetIterator = subnet.split(':')[Symbol.iterator]();
for (const ipSegment of ip.split(':')) {
if (bitsChecked >= mask) {
break;
}
const subnetSegment = subnetIterator.next().value;
const ipValue = Number.parseInt(ipSegment, 16);
const subnetValue = Number.parseInt(subnetSegment ?? '0', 16);
const bitsToCheck = Math.min(16, mask - bitsChecked);
const bitMask = (0xFFFF << (16 - bitsToCheck)) & 0xFFFF;
if ((ipValue & bitMask) !== (subnetValue & bitMask)) {
return false;
}
bitsChecked += 16;
}
return true;
};
/**
* Check if IP matches exact subnet
*/
const matchesExactIp = (ip, subnet) => {
return subnet === ip;
};
/**
* Check if IPv4 matches wildcard notation
*/
const matchesIpv4Wildcard = (ip, subnet) => {
if (!subnet.includes('*')) {
return false;
}
const ipOctets = ip.split('.');
const subnetOctets = subnet.split('.');
if (ipOctets.length !== 4 || subnetOctets.length !== 4) {
return false;
}
const ipOctetMap = new Map(ipOctets.entries());
for (const [index, subnetOctet] of subnetOctets.entries()) {
if (subnetOctet === '*') {
continue;
}
const ipOctet = ipOctetMap.get(index);
if (ipOctet == null || subnetOctet !== ipOctet) {
return false;
}
}
return true;
};
/**
* Check if IP matches CIDR subnet
*/
const matchesCidrSubnet = (ip, subnet, isIpv4, isIpv6) => {
if (!subnet.includes('/')) {
return false;
}
if (isIpv4 && subnet.includes('.')) {
return isIpv4InCidr(ip, subnet);
}
if (isIpv6 && subnet.includes(':')) {
return isIpv6InCidr(ip, subnet);
}
return false;
};
/**
* Determine IP version
*/
const getIpVersion = (ip) => {
const version = isIP(ip);
return {
isIpv4: version === 4,
isIpv6: version === 6
};
};
/**
* Check if an IP address is in allowed subnets
* Pure function for IP validation
*/
export const isIpInSubnets = (ip, allowedSubnets) => {
if (allowedSubnets.length === 0) {
return true; // No restrictions
}
const { isIpv4, isIpv6 } = getIpVersion(ip);
for (const subnet of allowedSubnets) {
if (matchesExactIp(ip, subnet)) {
return true;
}
if (matchesCidrSubnet(ip, subnet, isIpv4, isIpv6)) {
return true;
}
if (isIpv4 && matchesIpv4Wildcard(ip, subnet)) {
return true;
}
}
return false;
};
/**
* Validate connection with hostname resolution
* Async function that resolves hostnames and checks subnets
*/
export const validateConnectionWithDns = async (host, allowedSubnets) => {
// No restrictions if allowedSubnets is not configured
if (allowedSubnets == null || allowedSubnets.length === 0) {
debug(`No subnet restrictions for ${host}`);
return { ok: true, value: true };
}
// Resolve hostname to IP
const resolveResult = await resolveHostname(host);
if (resolveResult.ok) {
// Check if any resolved IP is in allowed subnets
const { addresses } = resolveResult.value;
for (const ip of addresses) {
if (isIpInSubnets(ip, allowedSubnets)) {
debug(`${host} (${ip}) is in allowed subnets`);
return { ok: true, value: true };
}
}
debug(`${host} (${addresses.join(', ')}) is not in allowed subnets`);
return {
ok: true,
value: false
};
}
else {
return {
ok: false,
error: resolveResult.error
};
}
};