Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions backend/routes/ssh.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,73 @@
const path = require('path');
const { execSync } = require('child_process');
const { NodeSSH } = require('node-ssh');
const dns = require('dns').promises;
const router = express.Router();

// Basic validation helpers to reduce SSRF risk
function isValidHostnameOrIP(value) {
if (typeof value !== 'string' || !value.trim()) {
return false;
}
const host = value.trim();
// Very simple hostname/IP check: disallow whitespace and slash characters.
if (/[\\s\\/]/.test(host)) {
return false;
}
return true;
}

function isDisallowedIp(ip) {
// Block localhost, private, link-local, and unspecified addresses.
if (ip === '127.0.0.1' || ip === '0.0.0.0') return true;
if (ip === '::1' || ip === '::') return true;
// 10.0.0.0/8
if (ip.startsWith('10.')) return true;
// 172.16.0.0/12
const firstOctets = ip.split('.');
if (firstOctets.length === 4) {
const first = parseInt(firstOctets[0], 10);
const second = parseInt(firstOctets[1], 10);
if (first === 172 && second >= 16 && second <= 31) {
return true;
}
// 192.168.0.0/16
if (first === 192 && second === 168) {
return true;
}
// 169.254.0.0/16 (link-local)
if (first === 169 && second === 254) {
return true;
}
}
return false;
}

async function resolveAndValidateHost(rawHost) {
if (!isValidHostnameOrIP(rawHost)) {
throw new Error('Invalid host value');
}
const host = rawHost.trim();
// Resolve the host to an IP address and reject disallowed ranges.
const lookupResult = await dns.lookup(host, { all: false });
const ip = lookupResult.address || lookupResult;
if (typeof ip !== 'string') {
throw new Error('Unable to resolve host');
}
if (isDisallowedIp(ip)) {
throw new Error('Connection to this host is not allowed');
}
return { host, ip };
}

function validatePort(rawPort) {
const portNum = Number(rawPort);
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
throw new Error('Invalid port value');
}
return portNum;
}

// Setup SSH key
router.post('/setup-key', async (req, res) => {
const { host, username, password, port = 22 } = req.body;
Expand Down Expand Up @@ -145,94 +210,109 @@
hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1'],
cipher: ['aes128-ctr', 'aes192-ctr', 'aes256-ctr', 'aes128-gcm', 'aes256-gcm'],
compress: ['none']
}
});

req.app.locals.logger.info('SSH key authentication test successful');

// Run a test command
const testCommand = await testSSH.execCommand('whoami');
req.app.locals.logger.info('Test command result', { result: testCommand });

await testSSH.dispose();

// Update configuration to mark SSH key as deployed
const configPath = path.join(req.app.locals.DATA_DIR, 'config.json');
if (await fs.pathExists(configPath)) {
const config = await fs.readJson(configPath);
config.sshKeyDeployed = true;
config.sshKeyPath = privateKeyPath;
config.updatedAt = new Date().toISOString();
await fs.writeJson(configPath, config, { spaces: 2 });
}

req.app.locals.logger.info('SSH key deployed and tested successfully', { host });
res.json({
success: true,
message: 'SSH key deployed successfully'
});

} catch (keyTestError) {
req.app.locals.logger.error('SSH key test failed', {
host,
error: keyTestError.message
});
res.json({
success: false,
error: `SSH key deployment failed: ${keyTestError.message}`
});
}

} catch (error) {
if (ssh) {
try {
await ssh.dispose();
} catch (e) {
// Ignore disposal errors
}
}

req.app.locals.logger.error('SSH connection failed during key deployment', {
host,
error: error.message
});

res.json({
success: false,
error: `SSH connection failed: ${error.message}`
});
}

} catch (error) {
req.app.locals.logger.error('Error setting up SSH key', { error: error.message });
res.status(500).json({ success: false, error: error.message });
}
});

// Debug SSH connectivity
router.post('/debug', async (req, res) => {
const { host, username, password, port = 22 } = req.body;

if (!host || !username || !password) {
return res.status(400).json({
success: false,
error: 'Missing required connection parameters'
});
}

let safeHost;
let safeIp;
let safePort;
try {
const resolved = await resolveAndValidateHost(host);
safeHost = resolved.host;
safeIp = resolved.ip;
safePort = validatePort(port);
} catch (validationError) {
return res.status(400).json({
success: false,
error: validationError.message
});
}

const debug = {
timestamp: new Date().toISOString(),
host,
port,
host: safeHost,
port: safePort,
username,
tests: {}
};

try {
// Test 1: Basic network connectivity
try {
const { execSync } = require('child_process');
const pingResult = execSync(`ping -c 1 -W 3 ${host}`, { timeout: 5000 }).toString();
const pingResult = execSync(`ping -c 1 -W 3 ${safeHost}`, { timeout: 5000 }).toString();

Check failure

Code scanning / CodeQL

Uncontrolled command line Critical

This command line depends on a
user-provided value
.
debug.tests.ping = { success: true, output: pingResult.trim() };
} catch (error) {
debug.tests.ping = { success: false, error: error.message };
Expand All @@ -249,7 +329,8 @@
reject(new Error('Connection timeout'));
}, 5000);

socket.connect(port, host, () => {
// Use validated port and resolved IP address
socket.connect(safePort, safeIp, () => {
clearTimeout(timeout);
socket.destroy();
resolve();

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
a system command
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
This route handler performs
a file system access
, but is not rate-limited.
Expand Down
Loading