fix(terminal): enhance WebSocket client verification with authorized IPs in terminal server

This commit is contained in:
Andras Bacsai
2025-04-25 15:35:23 +02:00
parent 2083ec1c0d
commit f5cc272861
3 changed files with 72 additions and 13 deletions

View File

@@ -4,7 +4,7 @@ return [
'coolify' => [
'version' => '4.0.0-beta.413',
'helper_version' => '1.0.8',
'realtime_version' => '1.0.7',
'realtime_version' => '1.0.8',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),

View File

@@ -3,7 +3,9 @@ import http from 'http';
import pty from 'node-pty';
import axios from 'axios';
import cookie from 'cookie';
import 'dotenv/config'
import 'dotenv/config';
const userSessions = new Map();
const server = http.createServer((req, res) => {
if (req.url === '/ready') {
@@ -15,16 +17,20 @@ const server = http.createServer((req, res) => {
}
});
const verifyClient = async (info, callback) => {
const cookies = cookie.parse(info.req.headers.cookie || '');
// const origin = new URL(info.origin);
// const protocol = origin.protocol;
const getSessionCookie = (req) => {
const cookies = cookie.parse(req.headers.cookie || '');
const xsrfToken = cookies['XSRF-TOKEN'];
// Generate session cookie name based on APP_NAME
const appName = process.env.APP_NAME || 'laravel';
const sessionCookieName = `${appName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase()}_session`;
const laravelSession = cookies[sessionCookieName];
return {
sessionCookieName,
xsrfToken: xsrfToken,
laravelSession: cookies[sessionCookieName]
}
}
const verifyClient = async (info, callback) => {
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(info.req);
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
@@ -54,11 +60,24 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
const userSessions = new Map();
wss.on('connection', (ws) => {
wss.on('connection', async (ws, req) => {
const userId = generateUserId();
const userSession = { ws, userId, ptyProcess: null, isActive: false };
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
ws.close(401, 'Unauthorized: Missing required tokens');
return;
}
const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
headers: {
'Cookie': `${sessionCookieName}=${laravelSession}`,
'X-XSRF-TOKEN': xsrfToken
},
});
userSession.authorizedIPs = response.data.ipAddresses || [];
userSessions.set(userId, userSession);
ws.on('message', (message) => {
@@ -125,6 +144,20 @@ async function handleCommand(ws, command, userId) {
const timeout = extractTimeout(commandString);
const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString);
// Extract target host from SSH command
const targetHost = extractTargetHost(sshArgs);
if (!targetHost) {
ws.send('Invalid SSH command: No target host found');
return;
}
// Validate target host against authorized IPs
if (!userSession.authorizedIPs.includes(targetHost)) {
ws.send(`Unauthorized: Target host ${targetHost} not in authorized list`);
return;
}
const options = {
name: 'xterm-color',
cols: 80,
@@ -152,7 +185,6 @@ async function handleCommand(ws, command, userId) {
console.error(`Process exited with code ${exitCode} and signal ${signal}`);
ws.send('pty-exited');
userSession.isActive = false;
});
if (timeout) {
@@ -162,6 +194,22 @@ async function handleCommand(ws, command, userId) {
}
}
function extractTargetHost(sshArgs) {
// Find the argument that matches the pattern user@host
const userAtHost = sshArgs.find(arg => {
// Skip paths that contain 'storage/app/ssh/keys/'
if (arg.includes('storage/app/ssh/keys/')) {
return false;
}
return /^[^@]+@[^@]+$/.test(arg);
});
if (!userAtHost) return null;
// Extract host from user@host
const host = userAtHost.split('@')[1];
return host;
}
async function handleError(err, userId) {
console.error('WebSocket error:', err);
await killPtyProcess(userId);

View File

@@ -149,6 +149,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
return response()->json(['authenticated' => false], 401);
})->name('terminal.auth');
Route::post('/terminal/auth/ips', function () {
if (auth()->check()) {
$team = auth()->user()->currentTeam();
$ipAddresses = $team->servers()->pluck('ip')->toArray();
return response()->json(['ipAddresses' => $ipAddresses], 200);
}
return response()->json(['ipAddresses' => []], 401);
})->name('terminal.auth.ips');
Route::prefix('invitations')->group(function () {
Route::get('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept');
Route::get('/{uuid}/revoke', [Controller::class, 'revoke_invitation'])->name('team.invitation.revoke');