From a5564320ab93a0550474adefe977ad7463c01ec4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:17:14 +0200 Subject: [PATCH] fix: improve terminal and realtime session handling Commit creation was blocked because the shared Git metadata is read-only in this session. All 14 changes remain staged and ready to commit. --- app/Livewire/Project/Shared/Terminal.php | 44 +----- app/Services/TerminalSessionService.php | 88 ++++++++++++ docker-compose-maxio.dev.yml | 2 +- docker-compose.dev-multi.yml | 2 +- docker-compose.dev.yml | 2 +- docker/coolify-terminal/terminal-server.js | 46 +++++- docker/coolify-terminal/terminal-utils.js | 3 + .../coolify-terminal/terminal-utils.test.js | 16 +++ resources/js/terminal.js | 21 +-- resources/views/layouts/base.blade.php | 2 +- routes/web.php | 10 ++ .../Feature/RealtimeTerminalPackagingTest.php | 10 +- .../ReverbAndTerminalPackagingTest.php | 14 ++ tests/Feature/TerminalSessionSecurityTest.php | 133 ++++++++++++++++++ 14 files changed, 323 insertions(+), 70 deletions(-) create mode 100644 app/Services/TerminalSessionService.php create mode 100644 tests/Feature/TerminalSessionSecurityTest.php diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index f6145807e4..de4e00cf20 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -2,8 +2,8 @@ namespace App\Livewire\Project\Shared; -use App\Helpers\SshMultiplexingHelper; use App\Models\Server; +use App\Services\TerminalSessionService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Attributes\On; @@ -37,7 +37,7 @@ class Terminal extends Component } #[On('send-terminal-command')] - public function sendTerminalCommand($isContainer, $identifier, $serverUuid) + public function sendTerminalCommand($isContainer, $identifier, $serverUuid, TerminalSessionService $terminalSessionService) { $this->authorize('canAccessTerminal'); @@ -65,44 +65,10 @@ class Terminal extends Component if (! $this->hasShell) { return; } - - // Escape the identifier for shell usage - $escapedIdentifier = escapeshellarg($identifier); - $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. - 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. - 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; - - // Add sudo for non-root users to access Docker socket - $dockerCommand = "docker exec -it {$escapedIdentifier} sh -c '{$shellCommand}'"; - if ($server->isNonRoot()) { - $dockerCommand = "sudo {$dockerCommand}"; - } - - $command = SshMultiplexingHelper::generateSshCommand( - $server, - $dockerCommand, - commandTimeout: (int) config('constants.terminal.command_timeout') - ); - } else { - $shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. - 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. - 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; - $command = SshMultiplexingHelper::generateSshCommand( - $server, - $shellCommand, - commandTimeout: (int) config('constants.terminal.command_timeout') - ); } - // ssh command is sent back to frontend then to websocket - // this is done because the websocket connection is not available here - // a better solution would be to remove websocket on NodeJS and work with something like - // 1. Laravel Pusher/Echo connection (not possible without a sdk) - // 2. Ratchet / Revolt / ReactPHP / Event Loop (possible but hard to implement and huge dependencies) - // 3. Just found out about this https://github.com/sirn-se/websocket-php, perhaps it can be used - // 4. Follow-up discussions here: - // - https://github.com/coollabsio/coolify/issues/2298 - // - https://github.com/coollabsio/coolify/discussions/3362 - $this->dispatch('send-back-command', $command); + + $token = $terminalSessionService->issue(auth()->user(), $server, $isContainer ? $identifier : null); + $this->dispatch('send-terminal-token', $token); } #[On('terminalConnected')] diff --git a/app/Services/TerminalSessionService.php b/app/Services/TerminalSessionService.php new file mode 100644 index 0000000000..eea260b5a5 --- /dev/null +++ b/app/Services/TerminalSessionService.php @@ -0,0 +1,88 @@ +cacheKey($token), [ + 'user_id' => $user->id, + 'team_id' => $user->currentTeam()->id, + 'server_uuid' => $server->uuid, + 'container' => $container, + ], now()->addSeconds(self::TOKEN_TTL_SECONDS)); + + return $token; + } + + public function redeem(User $user, string $token): string + { + $payload = Cache::lock($this->cacheKey($token).':lock', 5) + ->get(fn () => Cache::pull($this->cacheKey($token))); + + if (! is_array($payload) + || data_get($payload, 'user_id') !== $user->id + || data_get($payload, 'team_id') !== $user->currentTeam()->id + || ! is_string(data_get($payload, 'server_uuid')) + || ! array_key_exists('container', $payload)) { + throw new AccessDeniedHttpException('Invalid or expired terminal token.'); + } + + $server = Server::ownedByCurrentTeam() + ->whereUuid(data_get($payload, 'server_uuid')) + ->with('privateKey', 'settings') + ->firstOrFail(); + + if (! $server->isTerminalEnabled() + || $server->isForceDisabled() + || ! $server->privateKey + || $server->privateKey->team_id !== $user->currentTeam()->id) { + throw new AccessDeniedHttpException('Terminal target is not authorized.'); + } + + $command = $this->shellCommand(); + $container = $payload['container']; + + if ($container !== null) { + if (! is_string($container) + || ! ValidationPatterns::isValidContainerName($container) + || getContainerStatus($server, $container) !== 'running') { + throw new AccessDeniedHttpException('Terminal container is not authorized.'); + } + + $dockerCommand = 'docker exec -it '.escapeshellarg($container).' sh -c '.escapeshellarg($command); + $command = $server->isNonRoot() ? "sudo {$dockerCommand}" : $dockerCommand; + } + + return SshMultiplexingHelper::generateSshCommand( + $server, + $command, + commandTimeout: (int) config('constants.terminal.command_timeout') + ); + } + + private function shellCommand(): string + { + return 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '. + 'if [ -f ~/.profile ]; then . ~/.profile; fi && '. + 'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi'; + } + + private function cacheKey(string $token): string + { + return "terminal-session:{$token}"; + } +} diff --git a/docker-compose-maxio.dev.yml b/docker-compose-maxio.dev.yml index 3d4525510e..899e2edacf 100644 --- a/docker-compose-maxio.dev.yml +++ b/docker-compose-maxio.dev.yml @@ -16,7 +16,7 @@ services: - "host.docker.internal:host-gateway" environment: AUTORUN_ENABLED: false - PUSHER_HOST: "${PUSHER_HOST:-coolify}" + PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_PORT: "${PUSHER_PORT:-6001}" PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" diff --git a/docker-compose.dev-multi.yml b/docker-compose.dev-multi.yml index 26ba2236b6..c66a29948d 100644 --- a/docker-compose.dev-multi.yml +++ b/docker-compose.dev-multi.yml @@ -45,7 +45,7 @@ services: REDIS_PORT: 6379 REDIS_PASSWORD: "${REDIS_PASSWORD:-null}" COOLIFY_CONTAINER_ROLE: all - PUSHER_HOST: coolify + PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_PORT: 6001 PUSHER_BACKEND_PORT: 6001 PUSHER_SCHEME: http diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 6210d70bd4..8129dfa0fe 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,7 +17,7 @@ services: environment: AUTORUN_ENABLED: false COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}" - PUSHER_HOST: "${PUSHER_HOST:-coolify}" + PUSHER_HOST: "${PUSHER_HOST:-}" PUSHER_PORT: "${PUSHER_PORT:-6001}" PUSHER_BACKEND_PORT: "${PUSHER_BACKEND_PORT:-6001}" PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" diff --git a/docker/coolify-terminal/terminal-server.js b/docker/coolify-terminal/terminal-server.js index 00a9dd9590..a955247dca 100755 --- a/docker/coolify-terminal/terminal-server.js +++ b/docker/coolify-terminal/terminal-server.js @@ -15,14 +15,21 @@ import { validateSshArgs, } from './terminal-utils.js'; -async function postToCoolify(path, headers) { +async function postToCoolify(path, headers, body = null) { return new Promise((resolve, reject) => { + const requestBody = body === null ? '' : JSON.stringify(body); const request = http.request({ hostname: process.env.TERMINAL_AUTH_HOST || '127.0.0.1', port: 8080, path, method: 'POST', - headers, + headers: { + ...headers, + ...(body === null ? {} : { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(requestBody), + }), + }, }, (response) => { let responseText = ''; @@ -43,7 +50,7 @@ async function postToCoolify(path, headers) { }); request.on('error', reject); - request.end(); + request.end(requestBody); }); } @@ -167,6 +174,7 @@ wss.on('connection', async (ws, req) => { const userId = generateUserId(); ws.userId = userId; + const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const userSession = { ws, userId, @@ -176,8 +184,11 @@ wss.on('connection', async (ws, req) => { authReady: false, pendingMessages: [], terminalSessionTimer: null, + authHeaders: { + 'Cookie': `${sessionCookieName}=${laravelSession}`, + 'X-XSRF-TOKEN': xsrfToken, + }, }; - const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req); const connectionContext = { userId, remoteAddress: req.socket?.remoteAddress, @@ -289,7 +300,7 @@ const messageHandlers = { session.ws.send(session.isActive); } }, - command: (session, data) => handleCommand(session.ws, data, session.userId) + terminalToken: (session, token) => handleTerminalToken(session, token) }; function handleMessage(userSession, message) { @@ -304,7 +315,7 @@ function handleMessage(userSession, message) { Object.entries(parsed).forEach(([key, value]) => { const handler = messageHandlers[key]; - if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) { + if (handler && (userSession.isActive || key === 'checkActive' || key === 'terminalToken' || key === 'ping')) { handler(userSession, value); } else if (!handler) { logTerminal('warn', 'Ignoring websocket message with unknown handler key.', { @@ -446,6 +457,29 @@ async function handleCommand(ws, command, userId) { }, terminalSessionTimeout * 1000); } +async function handleTerminalToken(userSession, token) { + if (typeof token !== 'string' || !/^[a-zA-Z0-9]{64}$/.test(token)) { + userSession.ws.send('Unauthorized: Invalid terminal token'); + return; + } + + try { + const response = await postToCoolify('/terminal/session', userSession.authHeaders, { token }); + if (response.status !== 200 || typeof response.data?.command !== 'string') { + userSession.ws.send('Unauthorized: Terminal token was rejected'); + return; + } + + await handleCommand(userSession.ws, [response.data.command], userSession.userId); + } catch (error) { + logTerminal('error', 'Failed to redeem terminal token.', { + userId: userSession.userId, + error: error.message, + }); + userSession.ws.send('Unauthorized: Terminal token was rejected'); + } +} + async function handleError(err, userId) { logTerminal('error', 'WebSocket error.', { userId, diff --git a/docker/coolify-terminal/terminal-utils.js b/docker/coolify-terminal/terminal-utils.js index c2762f1d85..cc6d6fefd0 100644 --- a/docker/coolify-terminal/terminal-utils.js +++ b/docker/coolify-terminal/terminal-utils.js @@ -232,6 +232,9 @@ export function validateSshArgs(sshArgs, authorizedHosts = []) { if (/^[a-zA-Z0-9_][a-zA-Z0-9._-]*@[^@]+$/.test(argument) && targetHost === null) { targetHost = extractTargetHost([argument]); + if (!/^(?:[a-zA-Z0-9]|\[)/.test(targetHost ?? '')) { + return false; + } continue; } diff --git a/docker/coolify-terminal/terminal-utils.test.js b/docker/coolify-terminal/terminal-utils.test.js index e9acda3270..bc2525ded8 100644 --- a/docker/coolify-terminal/terminal-utils.test.js +++ b/docker/coolify-terminal/terminal-utils.test.js @@ -119,6 +119,22 @@ test('validateSshArgs rejects unknown SSH options and key paths', () => { assert.equal(validateSshArgs(['-F', '/tmp/config', ...baseArgs], ['10.0.0.5']), false); assert.equal(validateSshArgs(['-i', '/tmp/attacker-key', ...baseArgs.slice(2)], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-i', '/var/www/html/storage/app/ssh/keys/../ssh_key@victim', ...baseArgs.slice(2)], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-o', 'Include=/tmp/config', ...baseArgs], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-o', 'IdentityFile=/tmp/key', ...baseArgs], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-o', 'PermitLocalCommand=yes', ...baseArgs], ['10.0.0.5']), false); + assert.equal(validateSshArgs(['-o', 'LocalCommand=id', ...baseArgs], ['10.0.0.5']), false); +}); + +test('validateSshArgs rejects repeated flags and option-like connection values', () => { + const baseArgs = extractSshArgs( + "timeout 3600 ssh -i /var/www/html/storage/app/ssh/keys/ssh_key@cm123 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PasswordAuthentication=no -o ConnectTimeout=10 -o ServerAliveInterval=20 -o RequestTTY=no -o LogLevel=ERROR -p 22 root@10.0.0.5 'bash -se' << \\$abc\necho hi\nabc" + ); + + assert.equal(validateSshArgs(['-i', baseArgs[1], ...baseArgs], ['10.0.0.5']), false); + assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-p', '22', 'root@10.0.0.5'], ['10.0.0.5']), false); + assert.equal(validateSshArgs([...baseArgs.slice(0, -1), 'root@-oProxyCommand=id'], ['-oproxycommand=id']), false); + assert.equal(validateSshArgs([...baseArgs.slice(0, -1), '-root@10.0.0.5'], ['10.0.0.5']), false); }); test('validateSshArgs rejects a destination that begins with an option prefix', () => { diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 097a499c4b..e643a72510 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -185,10 +185,6 @@ export function initializeTerminalComponent() { maxHeartbeatMisses: 3, // Command buffering for race condition prevention pendingCommand: null, - // Last successfully sent SSH command — replayed after a transient reconnect - // so the PTY respawns automatically. Cleared on intentional terminations - // (pty-exited, unprocessable). - lastSentCommand: null, // Resize handling resizeObserver: null, resizeTimeout: null, @@ -290,8 +286,8 @@ export function initializeTerminalComponent() { this.setupTerminalEventListeners(); - this.$wire.on('send-back-command', (command) => { - this.sendCommandWhenReady({ command: command }); + this.$wire.on('send-terminal-token', ([token]) => { + this.sendCommandWhenReady({ terminalToken: token }); }); this.$wire.on('terminal-should-focus', () => { @@ -658,15 +654,11 @@ export function initializeTerminalComponent() { this.connectionTimeoutId = null; } - // Flush any buffered command from before WebSocket was ready, otherwise - // replay the last command so a transient reconnect respawns the PTY - // automatically without requiring the user to click Connect again. + // Flush a token that was issued before the WebSocket was ready. Issued + // tokens are single-use and must never be replayed after a reconnect. if (this.pendingCommand) { this.sendMessage(this.pendingCommand); this.pendingCommand = null; - } else if (this.lastSentCommand) { - logTerminal('log', '[Terminal] Replaying last command after reconnect.'); - this.sendMessage(this.lastSentCommand); } // (Re)start application-level keepalive on every successful connect. @@ -749,9 +741,6 @@ export function initializeTerminalComponent() { sendMessage(message) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); - if (message && message.command) { - this.lastSentCommand = message; - } } else { logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message); } @@ -822,7 +811,6 @@ export function initializeTerminalComponent() { this.starting = false; if (this.term) this.term.reset(); this.terminalActive = false; - this.lastSentCommand = null; this.resetTerminalSessionCountdown(); this.message = '(sorry, something went wrong, please try again)'; @@ -836,7 +824,6 @@ export function initializeTerminalComponent() { this.resetTerminalSessionCountdown(); this.term.reset(); this.commandBuffer = ''; - this.lastSentCommand = null; // Notify parent component that terminal disconnected this.$wire.dispatch('terminalDisconnected'); diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index 61df6d22f1..689b2fa0e8 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -289,7 +289,7 @@ wsHost: "{{ config('constants.pusher.host') }}" || window.location.hostname, wsPort: "{{ getRealtime() }}", wssPort: "{{ getRealtime() }}", - forceTLS: false, + forceTLS: window.location.protocol === 'https:', encrypted: true, enableStats: false, enableLogging: true, diff --git a/routes/web.php b/routes/web.php index 0539d719a9..712c415b79 100644 --- a/routes/web.php +++ b/routes/web.php @@ -111,6 +111,8 @@ use App\Models\ScheduledVolumeBackupExecution; use App\Models\Server; use App\Models\ServiceDatabase; use App\Providers\RouteServiceProvider; +use App\Services\TerminalSessionService; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; @@ -252,6 +254,14 @@ Route::middleware(['auth', 'verified'])->group(function () { return response()->json(['ipAddresses' => []], 401); })->name('terminal.auth.ips')->middleware('can.access.terminal'); + Route::post('/terminal/session', function (Request $request, TerminalSessionService $terminalSessionService) { + $request->validate(['token' => ['required', 'string', 'size:64']]); + + return response()->json([ + 'command' => $terminalSessionService->redeem($request->user(), $request->string('token')->toString()), + ]); + })->name('terminal.session')->middleware('can.access.terminal'); + Route::prefix('invitations')->group(function () { Route::get('/{uuid}', [Controller::class, 'showInvitation'])->name('team.invitation.show'); Route::post('/{uuid}', [Controller::class, 'acceptInvitation'])->name('team.invitation.accept'); diff --git a/tests/Feature/RealtimeTerminalPackagingTest.php b/tests/Feature/RealtimeTerminalPackagingTest.php index d144ebd696..4c4872f0d5 100644 --- a/tests/Feature/RealtimeTerminalPackagingTest.php +++ b/tests/Feature/RealtimeTerminalPackagingTest.php @@ -297,13 +297,15 @@ it('exits fullscreen when the terminal process exits', function () { this.terminalActive = false;'); }); -it('replays the last command on reconnect so the PTY respawns automatically', function () { +it('does not replay single-use terminal tokens after reconnect', function () { $terminalClient = file_get_contents(base_path('resources/js/terminal.js')); expect($terminalClient) - ->toContain('lastSentCommand') - ->toContain('Replaying last command after reconnect.') - ->toContain('this.lastSentCommand = null;'); + ->toContain('terminalToken') + ->toContain("this.\$wire.on('send-terminal-token', ([token]) =>") + ->toContain('tokens are single-use and must never be replayed') + ->not->toContain('lastSentCommand') + ->not->toContain('Replaying last command after reconnect.'); }); it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () { diff --git a/tests/Feature/ReverbAndTerminalPackagingTest.php b/tests/Feature/ReverbAndTerminalPackagingTest.php index 8ebd45231b..752eedbcce 100644 --- a/tests/Feature/ReverbAndTerminalPackagingTest.php +++ b/tests/Feature/ReverbAndTerminalPackagingTest.php @@ -148,6 +148,20 @@ it('keeps the internal Reverb listen port separate from the public Pusher port', ->toContain('exec php artisan reverb:start --host=0.0.0.0 --port=${PUSHER_BACKEND_PORT:-6001}'); }); +it('uses the browser host and page TLS mode for development Reverb connections', function () { + $baseLayout = file_get_contents(resource_path('views/layouts/base.blade.php')); + + expect(file_get_contents(base_path('docker-compose.dev.yml'))) + ->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"') + ->and(file_get_contents(base_path('docker-compose-maxio.dev.yml'))) + ->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"') + ->and(file_get_contents(base_path('docker-compose.dev-multi.yml'))) + ->toContain('PUSHER_HOST: "${PUSHER_HOST:-}"') + ->and($baseLayout) + ->toContain("forceTLS: window.location.protocol === 'https:'") + ->not->toContain('forceTLS: false'); +}); + it('proxies Reverb and terminal websocket traffic to the Coolify app container', function () { $serverModel = file_get_contents(app_path('Models/Server.php')); diff --git a/tests/Feature/TerminalSessionSecurityTest.php b/tests/Feature/TerminalSessionSecurityTest.php new file mode 100644 index 0000000000..dca3d59cbd --- /dev/null +++ b/tests/Feature/TerminalSessionSecurityTest.php @@ -0,0 +1,133 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +function terminalTokenPayload(User $user, Team $team, Server $server, ?string $container = null): array +{ + return [ + 'user_id' => $user->id, + 'team_id' => $team->id, + 'server_uuid' => $server->uuid, + 'container' => $container, + ]; +} + +it('issues an opaque terminal token without exposing connection data', function () { + $server = Server::factory()->make([ + 'uuid' => 'server-uuid', + 'ip' => '192.0.2.10', + 'team_id' => $this->team->id, + ]); + + $token = app(TerminalSessionService::class)->issue($this->user, $server); + + expect($token)->toHaveLength(64) + ->not->toContain($server->uuid) + ->not->toContain($server->ip); +}); + +it('rejects expired and replayed terminal tokens', function () { + $service = app(TerminalSessionService::class); + + expect(fn () => $service->redeem($this->user, str_repeat('a', 64))) + ->toThrow(AccessDeniedHttpException::class); + + Cache::put('terminal-session:'.str_repeat('b', 64), ['invalid' => true], now()->addMinute()); + + expect(fn () => $service->redeem($this->user, str_repeat('b', 64))) + ->toThrow(AccessDeniedHttpException::class) + ->and(Cache::has('terminal-session:'.str_repeat('b', 64)))->toBeFalse(); +}); + +it('rejects a cross-team server after token issue', function () { + $otherTeam = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $otherTeam->id]); + $token = str_repeat('c', 64); + Cache::put("terminal-session:{$token}", terminalTokenPayload($this->user, $this->team, $server), now()->addMinute()); + + $this->postJson('/terminal/session', ['token' => $token])->assertNotFound(); +}); + +it('rejects a server that references another teams private key', function () { + $otherTeam = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $otherTeam->id]); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + $token = str_repeat('d', 64); + Cache::put("terminal-session:{$token}", terminalTokenPayload($this->user, $this->team, $server), now()->addMinute()); + + $this->postJson('/terminal/session', ['token' => $token])->assertForbidden(); +}); + +it('constructs fixed ssh arguments from the authorized server record', function () { + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create([ + 'ip' => '192.0.2.25', + 'user' => 'root', + 'port' => 2222, + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + $token = app(TerminalSessionService::class)->issue($this->user, $server); + + $response = $this->postJson('/terminal/session', ['token' => $token]); + + $response->assertSuccessful(); + expect($response->json('command')) + ->toContain("ssh_key@{$privateKey->uuid}") + ->toContain("'2222'") + ->toContain("'root'@'192.0.2.25'") + ->not->toContain('ProxyCommand='); + + $this->postJson('/terminal/session', ['token' => $token])->assertForbidden(); +}); + +it('rejects option-like and traversal container identifiers before command construction', function (string $container) { + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + $token = str_repeat('g', 63).random_int(0, 9); + Cache::put("terminal-session:{$token}", terminalTokenPayload($this->user, $this->team, $server, $container), now()->addMinute()); + + $this->postJson('/terminal/session', ['token' => $token])->assertForbidden(); +})->with(['-oProxyCommand=id', '../other-container', 'container name']); + +it('denies members from redeeming terminal tokens', function () { + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $this->postJson('/terminal/session', ['token' => str_repeat('e', 64)])->assertForbidden(); +}); + +it('does not accept ssh arguments or identity paths from the client', function () { + $this->postJson('/terminal/session', [ + 'token' => str_repeat('f', 64), + 'sshArgs' => ['-o', 'ProxyCommand=id'], + 'identityFile' => '../../other-team-key', + 'target' => '-oProxyCommand=id', + ])->assertForbidden(); +});