fix(security): terminal logging cleanup, session ownership, host key verification, shell injection opt-in

- H-5: Redact keystroke data from WS message logs — log type/sessionId/bytes only
- H-4: Remove private key content/length/passphrase logging, replace with safe single line
- H-14: Remove username@hostname from password auth log, use hostId only
- M-1: Enforce session ownership in data/resize/disconnect handlers via clientSessions map
- C-5: Real host key verification flow — MITM protection blocks changed keys immediately,
  new hosts ask user via host-key-verify WS message with 30s timeout, pending map resolves on
  host-key-accept/host-key-reject response
- H-13: Shell PROMPT_COMMAND/precmd injection is now opt-in via options.enableCwdTracking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-14 14:15:23 -04:00
parent 74cba6339c
commit 39825f5295
2 changed files with 78 additions and 34 deletions

View File

@ -30,6 +30,7 @@ export class SshConnectionService {
onData: (data: string) => void,
onClose: (reason: string) => void,
onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<boolean>,
options?: { enableCwdTracking?: boolean },
): Promise<string> {
const host = await this.hosts.findOne(hostId);
const cred = host.credentialId
@ -55,6 +56,8 @@ 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.
// 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"; };` +
@ -63,6 +66,7 @@ export class SshConnectionService {
` 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}`);
}

View File

@ -7,6 +7,7 @@ export class TerminalGateway {
private readonly logger = new Logger(TerminalGateway.name);
private clientSessions = new Map<any, string[]>(); // ws client → sessionIds
private clientUsers = new Map<any, { sub: number; email: string }>(); // ws client → user
private pendingHostKeyVerifications = new Map<string, { resolve: (accepted: boolean) => 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<boolean>((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;
}
}
}