From 0c0bdbf2fe21875e8804f2e23e2a3c2295187dba Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 28 Apr 2025 10:45:02 +0200 Subject: [PATCH] v4.0.0-beta.413 (#5711) * feat(README): add InterviewPal sponsorship link and corresponding SVG icon * chore(versions): update coolify version to 4.0.0-beta.413 and nightly version to 4.0.0-beta.414 in configuration files * fix(terminal): enhance WebSocket client verification with authorized IPs in terminal server * chore(versions): update realtime version to 1.0.8 in versions.json * chore(versions): update realtime version to 1.0.8 in versions.json --- README.md | 2 + config/constants.php | 4 +- docker/coolify-realtime/terminal-server.js | 72 ++++++++++++++++++---- other/nightly/versions.json | 6 +- public/svgs/interviewpal.svg | 1 + routes/web.php | 11 ++++ versions.json | 6 +- 7 files changed, 82 insertions(+), 20 deletions(-) create mode 100644 public/svgs/interviewpal.svg diff --git a/README.md b/README.md index 6e245aa22..5f583df75 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ Thank you so much! Arvensis Systems Niklas Lausch Cap-go +InterviewPal + ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) diff --git a/config/constants.php b/config/constants.php index f05bc7d8e..a849ae93e 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,9 +2,9 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.412', + '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'), diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js index 6649f866c..61dd29453 100755 --- a/docker/coolify-realtime/terminal-server.js +++ b/docker/coolify-realtime/terminal-server.js @@ -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); diff --git a/other/nightly/versions.json b/other/nightly/versions.json index c3f4ba912..6dc9e7a02 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,16 +1,16 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.412" + "version": "4.0.0-beta.413" }, "nightly": { - "version": "4.0.0-beta.413" + "version": "4.0.0-beta.414" }, "helper": { "version": "1.0.8" }, "realtime": { - "version": "1.0.7" + "version": "1.0.8" }, "sentinel": { "version": "0.0.15" diff --git a/public/svgs/interviewpal.svg b/public/svgs/interviewpal.svg new file mode 100644 index 000000000..f0dc3731a --- /dev/null +++ b/public/svgs/interviewpal.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 3edfec910..3ddd77361 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/versions.json b/versions.json index c3f4ba912..6dc9e7a02 100644 --- a/versions.json +++ b/versions.json @@ -1,16 +1,16 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.412" + "version": "4.0.0-beta.413" }, "nightly": { - "version": "4.0.0-beta.413" + "version": "4.0.0-beta.414" }, "helper": { "version": "1.0.8" }, "realtime": { - "version": "1.0.7" + "version": "1.0.8" }, "sentinel": { "version": "0.0.15"