diff --git a/backend/src/terminal/ssh-connection.service.ts b/backend/src/terminal/ssh-connection.service.ts index d5fc621..1ad2d74 100644 --- a/backend/src/terminal/ssh-connection.service.ts +++ b/backend/src/terminal/ssh-connection.service.ts @@ -30,6 +30,7 @@ export class SshConnectionService { onData: (data: string) => void, onClose: (reason: string) => void, onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise, + options?: { enableCwdTracking?: boolean }, ): Promise { const host = await this.hosts.findOne(hostId); const cred = host.credentialId @@ -55,14 +56,17 @@ export class SshConnectionService { // OSC 7 escape sequences reporting the current working directory on every prompt. // Leading space prevents the command from being saved to shell history. // The frontend captures OSC 7 via xterm.js and syncs the SFTP sidebar. - const shellIntegration = - ` if [ -n "$ZSH_VERSION" ]; then` + - ` __wraith_cwd(){ printf '\\e]7;file://%s%s\\a' "$HOST" "$PWD"; };` + - ` precmd_functions+=(__wraith_cwd);` + - ` elif [ -n "$BASH_VERSION" ]; then` + - ` PROMPT_COMMAND='printf "\\033]7;file://%s%s\\a" "$HOSTNAME" "$PWD"';` + - ` fi\n`; - stream.write(shellIntegration); + // Only injected when the caller explicitly opts in via options.enableCwdTracking. + if (options?.enableCwdTracking) { + const shellIntegration = + ` if [ -n "$ZSH_VERSION" ]; then` + + ` __wraith_cwd(){ printf '\\e]7;file://%s%s\\a' "$HOST" "$PWD"; };` + + ` precmd_functions+=(__wraith_cwd);` + + ` elif [ -n "$BASH_VERSION" ]; then` + + ` PROMPT_COMMAND='printf "\\033]7;file://%s%s\\a" "$HOSTNAME" "$PWD"';` + + ` fi\n`; + stream.write(shellIntegration); + } stream.on('close', () => { this.disconnect(sessionId); @@ -100,13 +104,21 @@ export class SshConnectionService { const fp = `SHA256:${fingerprint}`; if (host.hostFingerprint === fp) { - verify(true); // known host — accept silently + verify(true); // known host — key matches, accept silently return; } - // Unknown or changed fingerprint — ask the user via WebSocket - const isNew = !host.hostFingerprint; - onHostKeyVerify(fp, isNew).then((accepted) => { + if (host.hostFingerprint && host.hostFingerprint !== fp) { + // Key has CHANGED from what was stored — possible MITM attack. Block immediately. + this.logger.warn( + `[SSH] HOST KEY CHANGED for hostId=${hostId} — possible MITM attack. Connection blocked.`, + ); + verify(false); + return; + } + + // No stored fingerprint — first connection, ask the user via WebSocket + onHostKeyVerify(fp, true).then((accepted) => { if (accepted) { // Persist fingerprint so future connections auto-accept this.prisma.host.update({ @@ -124,26 +136,10 @@ export class SshConnectionService { if (cred.sshKey.passphrase) { connectConfig.passphrase = cred.sshKey.passphrase; } - this.logger.log(`[SSH] Using key auth for ${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`); - this.logger.log(`[SSH] Key starts with: ${cred.sshKey.privateKey.substring(0, 40)}...`); - this.logger.log(`[SSH] Key length: ${cred.sshKey.privateKey.length}, has passphrase: ${!!cred.sshKey.passphrase}`); - - // Verify ssh2 can parse the key - try { - const parsed = utils.parseKey(cred.sshKey.privateKey, cred.sshKey.passphrase || undefined); - if (parsed instanceof Error) { - this.logger.error(`[SSH] Key parse FAILED: ${parsed.message}`); - } else { - const keyInfo = Array.isArray(parsed) ? parsed[0] : parsed; - this.logger.log(`[SSH] Key parsed OK — type: ${keyInfo.type}, comment: ${keyInfo.comment || 'none'}`); - this.logger.log(`[SSH] Public key fingerprint: ${keyInfo.getPublicSSH?.()?.toString('base64')?.substring(0, 40) || 'N/A'}`); - } - } catch (e: any) { - this.logger.error(`[SSH] Key parse threw: ${e.message}`); - } + this.logger.log(`[SSH] Using SSH key auth (type: ${cred.sshKey.keyType || 'unknown'})`); } else if (cred?.password) { connectConfig.password = cred.password; - this.logger.log(`[SSH] Using password auth for ${connectConfig.username}@${connectConfig.host}:${connectConfig.port}`); + this.logger.log(`[SSH] Using password auth for hostId=${hostId}`); } else { this.logger.warn(`[SSH] No auth method available for host ${hostId}`); } diff --git a/backend/src/terminal/terminal.gateway.ts b/backend/src/terminal/terminal.gateway.ts index dbba200..6c3e9aa 100644 --- a/backend/src/terminal/terminal.gateway.ts +++ b/backend/src/terminal/terminal.gateway.ts @@ -7,6 +7,7 @@ export class TerminalGateway { private readonly logger = new Logger(TerminalGateway.name); private clientSessions = new Map(); // ws client → sessionIds private clientUsers = new Map(); // ws client → user + private pendingHostKeyVerifications = new Map void }>(); // verifyId → resolver constructor( private ssh: SshConnectionService, @@ -28,7 +29,11 @@ export class TerminalGateway { client.on('message', async (raw: Buffer) => { try { const msg = JSON.parse(raw.toString()); - this.logger.log(`[WS] Message: ${msg.type} ${JSON.stringify(msg).substring(0, 200)}`); + if (msg.type === 'data') { + this.logger.log(`[WS-TERMINAL] type=data sessionId=${msg.sessionId} bytes=${msg.data?.length || 0}`); + } else { + this.logger.log(`[WS-TERMINAL] ${JSON.stringify(msg).substring(0, 200)}`); + } await this.handleMessage(client, msg); } catch (err: any) { this.logger.error(`[WS] handleMessage error: ${err.message}\n${err.stack}`); @@ -56,9 +61,25 @@ export class TerminalGateway { wsUser!.sub, (data) => this.send(client, { type: 'data', sessionId, data }), (reason) => this.send(client, { type: 'disconnected', sessionId, reason }), - async (fingerprint, isNew) => { - this.send(client, { type: 'host-key-verify', fingerprint, isNew }); - return true; // auto-accept for now, full flow in Task 12 + (fingerprint: string, isNew: boolean) => { + // New host — ask the user; changed host keys are rejected in the service before reaching here + return new Promise((resolve) => { + const verifyId = `${sessionId}-${Date.now()}`; + this.pendingHostKeyVerifications.set(verifyId, { resolve }); + this.send(client, { + type: 'host-key-verify', + verifyId, + fingerprint, + isNew, + }); + // Timeout after 30 seconds — reject if no response + setTimeout(() => { + if (this.pendingHostKeyVerifications.has(verifyId)) { + this.pendingHostKeyVerifications.delete(verifyId); + resolve(false); + } + }, 30000); + }); }, ); } catch (err: any) { @@ -73,23 +94,50 @@ export class TerminalGateway { break; } case 'data': { + // M-1: Verify the session belongs to this client before processing + const dataSessions = this.clientSessions.get(client); + if (!dataSessions || !dataSessions.includes(msg.sessionId)) { + this.send(client, { type: 'error', message: 'Session access denied' }); + return; + } if (msg.sessionId) { this.ssh.write(msg.sessionId, msg.data); } break; } case 'resize': { + // M-1: Verify the session belongs to this client before processing + const resizeSessions = this.clientSessions.get(client); + if (!resizeSessions || !resizeSessions.includes(msg.sessionId)) { + this.send(client, { type: 'error', message: 'Session access denied' }); + return; + } if (msg.sessionId) { this.ssh.resize(msg.sessionId, msg.cols, msg.rows); } break; } case 'disconnect': { + // M-1: Verify the session belongs to this client before processing + const disconnectSessions = this.clientSessions.get(client); + if (!disconnectSessions || !disconnectSessions.includes(msg.sessionId)) { + this.send(client, { type: 'error', message: 'Session access denied' }); + return; + } if (msg.sessionId) { this.ssh.disconnect(msg.sessionId); } break; } + case 'host-key-accept': + case 'host-key-reject': { + const pending = this.pendingHostKeyVerifications.get(msg.verifyId); + if (pending) { + pending.resolve(msg.type === 'host-key-accept'); + this.pendingHostKeyVerifications.delete(msg.verifyId); + } + break; + } } }