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:
parent
74cba6339c
commit
39825f5295
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user