1. WebSocket exec auth bypass (exec.js): Require valid JWT or API key before accepting WebSocket upgrade. Reject unauthenticated requests with 401 before the upgrade completes. 2. Shell injection in router auto-login (session-handlers.js): Validate baseUrl against safe hostname pattern before embedding in wget shell command. Reject with null session if invalid. 3. Path traversal in credentials routes (services.js): Add explicit serviceId validation (alphanumeric + dash/underscore/dot, max 100 chars) to all three credential endpoints. Removed redundant try/catch wrapper. 4. execSync injection in CA CSR generation (ca.js): Add sanitize step replacing any non-alphanumeric domain chars with underscore before interpolation into shell subj argument. Redundant with existing validation but provides defense-in-depth. 5. Auth bypass when TOTP disabled (middleware.js): Split the logic cleanly — disabled TOTP means no auth (initial setup state), enabled TOTP means all auth methods checked (session/JWT/API key). Removed the sessionDuration:never conflating shortcut.
175 lines
5.3 KiB
JavaScript
175 lines
5.3 KiB
JavaScript
const { WebSocketServer } = require('ws');
|
|
const Docker = require('dockerode');
|
|
const url = require('url');
|
|
|
|
const docker = new Docker();
|
|
|
|
/**
|
|
* Attach WebSocket server for container exec/shell
|
|
* Route: ws://host/ws/exec/:containerId
|
|
* @param {http.Server} server - The HTTP server instance
|
|
* @param {Object} log - Logger
|
|
*/
|
|
module.exports = function attachExecWS(server, log, authManager) {
|
|
const wss = new WebSocketServer({ noServer: true });
|
|
|
|
// Authenticate WebSocket upgrade request before accepting it
|
|
server.on('upgrade', async (req, socket, head) => {
|
|
const parsed = url.parse(req.url, true);
|
|
const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
|
|
if (!match) return; // Not our route — let other handlers deal with it
|
|
|
|
const containerId = decodeURIComponent(match[1]);
|
|
|
|
// Validate container ID format to prevent injection
|
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerId)) {
|
|
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
|
|
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
// Check auth — require valid JWT or API key from the upgrade request
|
|
let auth = null;
|
|
const authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
const token = authHeader.substring(7);
|
|
const payload = await authManager.verifyJWT(token);
|
|
if (payload) {
|
|
auth = { type: 'jwt', userId: payload.userId, scope: payload.scope || [] };
|
|
}
|
|
} else {
|
|
const apiKey = req.headers['x-api-key'];
|
|
if (apiKey) {
|
|
const keyData = await authManager.verifyAPIKey(apiKey);
|
|
if (keyData) {
|
|
auth = { type: 'apikey', keyId: keyData.keyId, scope: keyData.scopes || [] };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!auth) {
|
|
log.warn('exec', 'Unauthenticated WebSocket exec attempt', { containerId, ip: req.socket.remoteAddress });
|
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
// Auth passed — proceed with WebSocket upgrade
|
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
handleExec(ws, containerId, log, auth);
|
|
});
|
|
});
|
|
|
|
return wss;
|
|
};
|
|
|
|
async function handleExec(ws, containerId, log, auth) {
|
|
let execStream = null;
|
|
let execInstance = null;
|
|
|
|
try {
|
|
const container = docker.getContainer(containerId);
|
|
// Verify container exists and is running
|
|
const info = await container.inspect();
|
|
if (!info.State.Running) {
|
|
ws.send(JSON.stringify({ type: 'error', message: 'Container is not running' }));
|
|
ws.close();
|
|
return;
|
|
}
|
|
|
|
log.info('exec', 'Authenticated exec session started', {
|
|
containerId,
|
|
authType: auth.type,
|
|
authId: auth.type === 'jwt' ? auth.userId : auth.keyId
|
|
});
|
|
|
|
// Detect available shell
|
|
let shell = '/bin/sh';
|
|
try {
|
|
const bashCheck = await container.exec({ Cmd: ['which', 'bash'], AttachStdout: true });
|
|
const bashStream = await bashCheck.start();
|
|
const chunks = [];
|
|
await new Promise((resolve) => {
|
|
bashStream.on('data', (chunk) => chunks.push(chunk));
|
|
bashStream.on('end', resolve);
|
|
});
|
|
if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) {
|
|
shell = '/bin/bash';
|
|
}
|
|
} catch (_) {
|
|
// Fall back to /bin/sh when bash detection fails
|
|
}
|
|
|
|
execInstance = await container.exec({
|
|
Cmd: [shell],
|
|
AttachStdin: true,
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Tty: true,
|
|
});
|
|
|
|
execStream = await execInstance.start({ hijack: true, stdin: true, Tty: true });
|
|
|
|
ws.send(JSON.stringify({ type: 'connected', shell, containerId }));
|
|
|
|
// Docker → WebSocket
|
|
execStream.on('data', (chunk) => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(chunk);
|
|
}
|
|
});
|
|
|
|
execStream.on('end', () => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'exit' }));
|
|
ws.close();
|
|
}
|
|
});
|
|
|
|
// WebSocket → Docker
|
|
ws.on('message', (data) => {
|
|
if (!execStream.writable) return;
|
|
try {
|
|
// Check for control messages (JSON)
|
|
const str = data.toString();
|
|
if (str.startsWith('{"type":')) {
|
|
const msg = JSON.parse(str);
|
|
if (msg.type === 'resize' && execInstance && msg.cols && msg.rows) {
|
|
execInstance.resize({ h: msg.rows, w: msg.cols }).catch(() => {});
|
|
return;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Treat message as raw terminal input if JSON parsing fails
|
|
}
|
|
// Regular terminal input
|
|
execStream.write(data);
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
if (execStream) {
|
|
try { execStream.destroy(); } catch (_) {
|
|
// Ignore stream teardown errors on socket close
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on('error', (err) => {
|
|
log.warn('exec', 'WebSocket error', { containerId, error: err.message });
|
|
if (execStream) {
|
|
try { execStream.destroy(); } catch (_) {
|
|
// Ignore stream teardown errors after websocket errors
|
|
}
|
|
}
|
|
});
|
|
|
|
} catch (err) {
|
|
log.error('exec', 'Failed to start exec session', { containerId, error: err.message });
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'error', message: err.message }));
|
|
ws.close();
|
|
}
|
|
}
|
|
}
|