mirror of
https://github.com/MrUnknownDE/utools.git
synced 2026-04-06 00:32:04 +02:00
353 lines
14 KiB
JavaScript
353 lines
14 KiB
JavaScript
// backend/utils.js
|
|
const net = require('net'); // Node.js built-in module for IP validation
|
|
const { spawn } = require('child_process');
|
|
const pino = require('pino'); // Import pino for logging within utils if needed
|
|
const Sentry = require("@sentry/node"); // Import Sentry for error reporting
|
|
|
|
// Logger instance (assuming a logger is initialized elsewhere and passed or created here)
|
|
// For simplicity, creating a basic logger here. Ideally, pass the main logger instance.
|
|
const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
|
|
|
|
/**
|
|
* Validiert eine IP-Adresse (v4 oder v6) mit Node.js' eingebautem net Modul.
|
|
* @param {string} ip - Die zu validierende IP-Adresse.
|
|
* @returns {boolean} True, wenn gültig (als v4 oder v6), sonst false.
|
|
*/
|
|
function isValidIp(ip) {
|
|
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
|
|
return false;
|
|
}
|
|
const trimmedIp = ip.trim();
|
|
const ipVersion = net.isIP(trimmedIp); // Gibt 0, 4 oder 6 zurück
|
|
return ipVersion === 4 || ipVersion === 6;
|
|
}
|
|
|
|
/**
|
|
* Prüft, ob eine IP-Adresse im privaten, Loopback- oder Link-Local-Bereich liegt.
|
|
* @param {string} ip - Die zu prüfende IP-Adresse (bereits validiert).
|
|
* @returns {boolean} True, wenn die IP privat/lokal ist, sonst false.
|
|
*/
|
|
function isPrivateIp(ip) {
|
|
if (!ip) return false;
|
|
|
|
// Normalize IPv6-mapped IPv4 addresses (e.g., ::ffff:192.168.1.1 -> 192.168.1.1)
|
|
if (ip.startsWith('::ffff:')) {
|
|
ip = ip.substring(7);
|
|
}
|
|
|
|
const ipVersion = net.isIP(ip);
|
|
|
|
if (ipVersion === 4) {
|
|
const parts = ip.split('.').map(Number);
|
|
return (
|
|
parts[0] === 10 || // 10.0.0.0/8
|
|
(parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12
|
|
(parts[0] === 192 && parts[1] === 168) || // 192.168.0.0/16
|
|
parts[0] === 127 || // 127.0.0.0/8 (Loopback)
|
|
(parts[0] === 169 && parts[1] === 254) || // 169.254.0.0/16 (Link-local)
|
|
// Block 0.0.0.0 (Commonly "Any" or "Current Network")
|
|
(parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0)
|
|
);
|
|
} else if (ipVersion === 6) {
|
|
const lowerCaseIp = ip.toLowerCase();
|
|
return (
|
|
lowerCaseIp === '::1' || // ::1/128 (Loopback)
|
|
lowerCaseIp === '::' || // ::/128 (Unspecified)
|
|
lowerCaseIp.startsWith('fc') || lowerCaseIp.startsWith('fd') || // fc00::/7 (Unique Local)
|
|
lowerCaseIp.startsWith('fe8') || lowerCaseIp.startsWith('fe9') || // fe80::/10 (Link-local)
|
|
lowerCaseIp.startsWith('fea') || lowerCaseIp.startsWith('feb')
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Validiert einen Domainnamen (sehr einfache Prüfung).
|
|
* @param {string} domain - Der zu validierende Domainname.
|
|
* @returns {boolean} True, wenn wahrscheinlich gültig, sonst false.
|
|
*/
|
|
function isValidDomain(domain) {
|
|
if (!domain || typeof domain !== 'string' || domain.trim().length < 3) {
|
|
return false;
|
|
}
|
|
// Regex updated to be more robust and handle international characters (IDNs)
|
|
const domainRegex = /^(?:[a-z0-9\p{L}](?:[a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}])?\.)+[a-z0-9\p{L}][a-z0-9\p{L}-]{0,61}[a-z0-9\p{L}]$/iu;
|
|
return domainRegex.test(domain.trim());
|
|
}
|
|
|
|
/**
|
|
* Validiert eine MAC-Adresse.
|
|
* @param {string} mac - Die zu validierende MAC-Adresse.
|
|
* @returns {boolean} True, wenn das Format gültig ist, sonst false.
|
|
*/
|
|
function isValidMacAddress(mac) {
|
|
if (!mac || typeof mac !== 'string') {
|
|
return false;
|
|
}
|
|
// This regex matches common MAC address formats (e.g., 00:1A:2B:3C:4D:5E, 00-1A-2B-3C-4D-5E, 001A2B3C4D5E)
|
|
const macRegex = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$|^([0-9A-Fa-f]{12})$/;
|
|
return macRegex.test(mac.trim());
|
|
}
|
|
|
|
|
|
/**
|
|
* Bereinigt eine IP-Adresse (z.B. entfernt ::ffff: Präfix von IPv4-mapped IPv6).
|
|
* @param {string} ip - Die IP-Adresse.
|
|
* @returns {string} Die bereinigte IP-Adresse.
|
|
*/
|
|
function getCleanIp(ip) {
|
|
if (!ip) return ip;
|
|
const trimmedIp = ip.trim();
|
|
if (trimmedIp.startsWith('::ffff:')) {
|
|
const potentialIp4 = trimmedIp.substring(7);
|
|
if (net.isIP(potentialIp4) === 4) {
|
|
return potentialIp4;
|
|
}
|
|
}
|
|
// Keep localhost IPs as they are
|
|
if (trimmedIp === '::1' || trimmedIp === '127.0.0.1') {
|
|
return trimmedIp;
|
|
}
|
|
// Return trimmed IP for other cases
|
|
return trimmedIp;
|
|
}
|
|
|
|
|
|
/**
|
|
* Führt einen Shell-Befehl sicher aus und gibt stdout zurück. (Nur für Ping verwendet)
|
|
* @param {string} command - Der Befehl (z.B. 'ping').
|
|
* @param {string[]} args - Die Argumente als Array.
|
|
* @returns {Promise<string>} Eine Promise, die mit stdout aufgelöst wird.
|
|
*/
|
|
function executeCommand(command, args) {
|
|
return new Promise((resolve, reject) => {
|
|
// Basic argument validation
|
|
args.forEach(arg => {
|
|
if (typeof arg === 'string' && /[;&|`$()<>]/.test(arg)) {
|
|
const error = new Error(`Invalid character detected in command argument.`);
|
|
logger.error({ command, arg }, "Potential command injection attempt detected in argument");
|
|
Sentry.captureException(error); // Send to Sentry
|
|
return reject(error);
|
|
}
|
|
});
|
|
|
|
const proc = spawn(command, args);
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
proc.on('error', (err) => {
|
|
const error = new Error(`Failed to start command ${command}: ${err.message}`);
|
|
logger.error({ command, args, error: err.message }, `Failed to start command`);
|
|
Sentry.captureException(error); // Send to Sentry
|
|
reject(error);
|
|
});
|
|
proc.on('close', (code) => {
|
|
if (code !== 0) {
|
|
const error = new Error(`Command ${command} failed with code ${code}: ${stderr || 'No stderr output'}`);
|
|
// Attach stdout/stderr to the error object for better context in rejection
|
|
error.stdout = stdout;
|
|
error.stderr = stderr;
|
|
logger.error({ command, args, exitCode: code, stderr: stderr.trim(), stdout: stdout.trim() }, `Command failed`);
|
|
Sentry.captureException(error, { extra: { stdout: stdout.trim(), stderr: stderr.trim() } }); // Send to Sentry
|
|
reject(error);
|
|
} else {
|
|
resolve(stdout);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Parst die Ausgabe des Linux/macOS ping Befehls.
|
|
* @param {string} pingOutput - Die rohe stdout Ausgabe von ping.
|
|
* @returns {object} Ein Objekt mit geparsten Daten oder Fehlern.
|
|
*/
|
|
function parsePingOutput(pingOutput) {
|
|
const result = {
|
|
rawOutput: pingOutput,
|
|
stats: null,
|
|
error: null,
|
|
};
|
|
|
|
try {
|
|
let packetsTransmitted = 0;
|
|
let packetsReceived = 0;
|
|
let packetLossPercent = 100;
|
|
let rtt = { min: null, avg: null, max: null, mdev: null };
|
|
|
|
const lines = pingOutput.trim().split('\n');
|
|
const statsLine = lines.find(line => line.includes('packets transmitted'));
|
|
if (statsLine) {
|
|
const transmittedMatch = statsLine.match(/(\d+)\s+packets transmitted/);
|
|
const receivedMatch = statsLine.match(/(\d+)\s+(?:received|packets received)/);
|
|
const lossMatch = statsLine.match(/([\d.]+)%\s+packet loss/);
|
|
if (transmittedMatch) packetsTransmitted = parseInt(transmittedMatch[1], 10);
|
|
if (receivedMatch) packetsReceived = parseInt(receivedMatch[1], 10);
|
|
if (lossMatch) packetLossPercent = parseFloat(lossMatch[1]);
|
|
}
|
|
|
|
// Handle both 'rtt' and 'round-trip' prefixes for broader compatibility
|
|
const rttLine = lines.find(line => line.startsWith('rtt min/avg/max/mdev') || line.startsWith('round-trip min/avg/max/stddev'));
|
|
if (rttLine) {
|
|
const rttMatch = rttLine.match(/([\d.]+)\/([\d.]+)\/([\d.]+)\/([\d.]+)/);
|
|
if (rttMatch) {
|
|
rtt = {
|
|
min: parseFloat(rttMatch[1]),
|
|
avg: parseFloat(rttMatch[2]),
|
|
max: parseFloat(rttMatch[3]),
|
|
mdev: parseFloat(rttMatch[4]), // Note: mdev/stddev might have different meanings
|
|
};
|
|
}
|
|
}
|
|
|
|
result.stats = {
|
|
packets: { transmitted: packetsTransmitted, received: packetsReceived, lossPercent: packetLossPercent },
|
|
rtt: rtt.avg !== null ? rtt : null, // Only include RTT if average is available
|
|
};
|
|
|
|
// Check for common error messages or patterns
|
|
if (packetsTransmitted > 0 && packetsReceived === 0) {
|
|
result.error = "Request timed out or host unreachable.";
|
|
} else if (pingOutput.includes('unknown host') || pingOutput.includes('Name or service not known')) {
|
|
result.error = "Unknown host.";
|
|
}
|
|
|
|
} catch (parseError) {
|
|
logger.error({ error: parseError.message, output: pingOutput }, "Failed to parse ping output");
|
|
Sentry.captureException(parseError, { extra: { pingOutput } }); // Send to Sentry
|
|
result.error = "Failed to parse ping output.";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
|
|
/**
|
|
* Parst eine einzelne Zeile der Linux/macOS traceroute Ausgabe.
|
|
* @param {string} line - Eine Zeile aus stdout.
|
|
* @returns {object | null} Ein Objekt mit Hop-Daten oder null bei uninteressanten Zeilen.
|
|
*/
|
|
function parseTracerouteLine(line) {
|
|
line = line.trim();
|
|
// Ignore header lines and empty lines
|
|
if (!line || line.startsWith('traceroute to') || line.includes('hops max')) return null;
|
|
|
|
// Regex to capture hop number, hostname (optional), IP address, and RTT times
|
|
// Handles cases with or without hostname, and different spacing
|
|
const hopMatch = line.match(/^(\s*\d+)\s+(?:([a-zA-Z0-9\.\-]+)\s+\(([\d\.:a-fA-F]+)\)|([\d\.:a-fA-F]+))\s+(.*)$/);
|
|
const timeoutMatch = line.match(/^(\s*\d+)\s+(\*\s+\*\s+\*)/); // Match lines with only timeouts
|
|
|
|
if (timeoutMatch) {
|
|
// Handle timeout line
|
|
return {
|
|
hop: parseInt(timeoutMatch[1].trim(), 10),
|
|
hostname: null,
|
|
ip: null,
|
|
rtt: ['*', '*', '*'], // Represent timeouts as '*'
|
|
rawLine: line,
|
|
};
|
|
} else if (hopMatch) {
|
|
// Handle successful hop line
|
|
const hop = parseInt(hopMatch[1].trim(), 10);
|
|
const hostname = hopMatch[2]; // Hostname if present
|
|
const ipInParen = hopMatch[3]; // IP if hostname is present
|
|
const ipDirect = hopMatch[4]; // IP if hostname is not present
|
|
const restOfLine = hopMatch[5].trim();
|
|
const ip = ipInParen || ipDirect; // Determine the correct IP
|
|
|
|
// Extract RTT times, handling '*' for timeouts and removing ' ms' units
|
|
const rttParts = restOfLine.split(/\s+/);
|
|
const rtts = rttParts
|
|
.map(p => p === '*' ? '*' : p.replace(/\s*ms$/, '')) // Keep '*' or remove ' ms'
|
|
.filter(p => p === '*' || !isNaN(parseFloat(p))) // Ensure it's '*' or a number
|
|
.slice(0, 3); // Take the first 3 valid RTT values
|
|
|
|
// Pad with '*' if fewer than 3 RTTs were found (e.g., due to timeouts)
|
|
while (rtts.length < 3) rtts.push('*');
|
|
|
|
return {
|
|
hop: hop,
|
|
hostname: hostname || null, // Use null if hostname wasn't captured
|
|
ip: ip,
|
|
rtt: rtts,
|
|
rawLine: line,
|
|
};
|
|
}
|
|
|
|
// Return null if the line doesn't match expected formats
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Checks if a specific TCP port is open on a given host.
|
|
* @param {number} port - The port to check.
|
|
* @param {string} host - The target host IP address.
|
|
* @param {number} timeout - Connection timeout in milliseconds.
|
|
* @returns {Promise<{port: number, status: 'open'|'closed'|'timeout', service: string}>} A promise that resolves with the port status.
|
|
*/
|
|
function checkPort(port, host, timeout = 2000) {
|
|
// A small map of common ports to their services
|
|
const commonPorts = {
|
|
21: 'FTP', 22: 'SSH', 23: 'Telnet', 25: 'SMTP', 53: 'DNS', 80: 'HTTP',
|
|
110: 'POP3', 143: 'IMAP', 443: 'HTTPS', 445: 'SMB', 993: 'IMAPS',
|
|
995: 'POP3S', 1433: 'MSSQL', 1521: 'Oracle', 3306: 'MySQL', 3389: 'RDP',
|
|
5432: 'PostgreSQL', 5900: 'VNC', 8080: 'HTTP-Alt', 8443: 'HTTPS-Alt'
|
|
};
|
|
const service = commonPorts[port] || 'Unknown';
|
|
|
|
return new Promise((resolve, reject) => {
|
|
// DEFENSE IN DEPTH: Prevent scanning of private IPs at the function level
|
|
if (!isValidIp(host) || isPrivateIp(host)) {
|
|
const error = new Error(`Scanning restricted: ${host} is not a valid public IP.`);
|
|
logger.warn({ host, port }, "Blocked attempt to scan restricted IP in checkPort");
|
|
return resolve({
|
|
port,
|
|
status: 'error',
|
|
service,
|
|
error: 'Restricted IP',
|
|
details: 'Scanning private or invalid IPs is not allowed.'
|
|
});
|
|
}
|
|
|
|
const socket = new net.Socket();
|
|
socket.setTimeout(timeout);
|
|
|
|
socket.on('connect', () => {
|
|
socket.destroy();
|
|
resolve({ port, status: 'open', service });
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
resolve({ port, status: 'timeout', service });
|
|
});
|
|
|
|
socket.on('error', (err) => {
|
|
socket.destroy();
|
|
// 'ECONNREFUSED' is the key for a closed port. Other errors might be network issues.
|
|
const status = err.code === 'ECONNREFUSED' ? 'closed' : 'error';
|
|
resolve({ port, status, service, error: err.code });
|
|
});
|
|
|
|
// Explicit inline guard (defence-in-depth; also satisfies CodeQL SSRF dataflow)
|
|
if (!isValidIp(host) || isPrivateIp(host)) {
|
|
socket.destroy();
|
|
return resolve({ port, status: 'error', service, error: 'Restricted IP' });
|
|
}
|
|
socket.connect(port, host);
|
|
});
|
|
}
|
|
|
|
|
|
module.exports = {
|
|
isValidIp,
|
|
isPrivateIp,
|
|
isValidDomain,
|
|
isValidMacAddress,
|
|
getCleanIp,
|
|
executeCommand,
|
|
parsePingOutput,
|
|
parseTracerouteLine,
|
|
checkPort,
|
|
}; |