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,
|
onData: (data: string) => void,
|
||||||
onClose: (reason: string) => void,
|
onClose: (reason: string) => void,
|
||||||
onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<boolean>,
|
onHostKeyVerify: (fingerprint: string, isNew: boolean) => Promise<boolean>,
|
||||||
|
options?: { enableCwdTracking?: boolean },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const host = await this.hosts.findOne(hostId);
|
const host = await this.hosts.findOne(hostId);
|
||||||
const cred = host.credentialId
|
const cred = host.credentialId
|
||||||
@ -55,14 +56,17 @@ export class SshConnectionService {
|
|||||||
// OSC 7 escape sequences reporting the current working directory on every prompt.
|
// OSC 7 escape sequences reporting the current working directory on every prompt.
|
||||||
// Leading space prevents the command from being saved to shell history.
|
// Leading space prevents the command from being saved to shell history.
|
||||||
// The frontend captures OSC 7 via xterm.js and syncs the SFTP sidebar.
|
// The frontend captures OSC 7 via xterm.js and syncs the SFTP sidebar.
|
||||||
const shellIntegration =
|
// Only injected when the caller explicitly opts in via options.enableCwdTracking.
|
||||||
` if [ -n "$ZSH_VERSION" ]; then` +
|
if (options?.enableCwdTracking) {
|
||||||
` __wraith_cwd(){ printf '\\e]7;file://%s%s\\a' "$HOST" "$PWD"; };` +
|
const shellIntegration =
|
||||||
` precmd_functions+=(__wraith_cwd);` +
|
` if [ -n "$ZSH_VERSION" ]; then` +
|
||||||
` elif [ -n "$BASH_VERSION" ]; then` +
|
` __wraith_cwd(){ printf '\\e]7;file://%s%s\\a' "$HOST" "$PWD"; };` +
|
||||||
` PROMPT_COMMAND='printf "\\033]7;file://%s%s\\a" "$HOSTNAME" "$PWD"';` +
|
` precmd_functions+=(__wraith_cwd);` +
|
||||||
` fi\n`;
|
` elif [ -n "$BASH_VERSION" ]; then` +
|
||||||
stream.write(shellIntegration);
|
` PROMPT_COMMAND='printf "\\033]7;file://%s%s\\a" "$HOSTNAME" "$PWD"';` +
|
||||||
|
` fi\n`;
|
||||||
|
stream.write(shellIntegration);
|
||||||
|
}
|
||||||
|
|
||||||
stream.on('close', () => {
|
stream.on('close', () => {
|
||||||
this.disconnect(sessionId);
|
this.disconnect(sessionId);
|
||||||
@ -100,13 +104,21 @@ export class SshConnectionService {
|
|||||||
const fp = `SHA256:${fingerprint}`;
|
const fp = `SHA256:${fingerprint}`;
|
||||||
|
|
||||||
if (host.hostFingerprint === fp) {
|
if (host.hostFingerprint === fp) {
|
||||||
verify(true); // known host — accept silently
|
verify(true); // known host — key matches, accept silently
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown or changed fingerprint — ask the user via WebSocket
|
if (host.hostFingerprint && host.hostFingerprint !== fp) {
|
||||||
const isNew = !host.hostFingerprint;
|
// Key has CHANGED from what was stored — possible MITM attack. Block immediately.
|
||||||
onHostKeyVerify(fp, isNew).then((accepted) => {
|
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) {
|
if (accepted) {
|
||||||
// Persist fingerprint so future connections auto-accept
|
// Persist fingerprint so future connections auto-accept
|
||||||
this.prisma.host.update({
|
this.prisma.host.update({
|
||||||
@ -124,26 +136,10 @@ export class SshConnectionService {
|
|||||||
if (cred.sshKey.passphrase) {
|
if (cred.sshKey.passphrase) {
|
||||||
connectConfig.passphrase = 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] Using SSH key auth (type: ${cred.sshKey.keyType || 'unknown'})`);
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
} else if (cred?.password) {
|
} else if (cred?.password) {
|
||||||
connectConfig.password = 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 {
|
} else {
|
||||||
this.logger.warn(`[SSH] No auth method available for host ${hostId}`);
|
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 readonly logger = new Logger(TerminalGateway.name);
|
||||||
private clientSessions = new Map<any, string[]>(); // ws client → sessionIds
|
private clientSessions = new Map<any, string[]>(); // ws client → sessionIds
|
||||||
private clientUsers = new Map<any, { sub: number; email: string }>(); // ws client → user
|
private clientUsers = new Map<any, { sub: number; email: string }>(); // ws client → user
|
||||||
|
private pendingHostKeyVerifications = new Map<string, { resolve: (accepted: boolean) => void }>(); // verifyId → resolver
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private ssh: SshConnectionService,
|
private ssh: SshConnectionService,
|
||||||
@ -28,7 +29,11 @@ export class TerminalGateway {
|
|||||||
client.on('message', async (raw: Buffer) => {
|
client.on('message', async (raw: Buffer) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(raw.toString());
|
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);
|
await this.handleMessage(client, msg);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[WS] handleMessage error: ${err.message}\n${err.stack}`);
|
this.logger.error(`[WS] handleMessage error: ${err.message}\n${err.stack}`);
|
||||||
@ -56,9 +61,25 @@ export class TerminalGateway {
|
|||||||
wsUser!.sub,
|
wsUser!.sub,
|
||||||
(data) => this.send(client, { type: 'data', sessionId, data }),
|
(data) => this.send(client, { type: 'data', sessionId, data }),
|
||||||
(reason) => this.send(client, { type: 'disconnected', sessionId, reason }),
|
(reason) => this.send(client, { type: 'disconnected', sessionId, reason }),
|
||||||
async (fingerprint, isNew) => {
|
(fingerprint: string, isNew: boolean) => {
|
||||||
this.send(client, { type: 'host-key-verify', fingerprint, isNew });
|
// New host — ask the user; changed host keys are rejected in the service before reaching here
|
||||||
return true; // auto-accept for now, full flow in Task 12
|
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) {
|
} catch (err: any) {
|
||||||
@ -73,23 +94,50 @@ export class TerminalGateway {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'data': {
|
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) {
|
if (msg.sessionId) {
|
||||||
this.ssh.write(msg.sessionId, msg.data);
|
this.ssh.write(msg.sessionId, msg.data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'resize': {
|
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) {
|
if (msg.sessionId) {
|
||||||
this.ssh.resize(msg.sessionId, msg.cols, msg.rows);
|
this.ssh.resize(msg.sessionId, msg.cols, msg.rows);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'disconnect': {
|
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) {
|
if (msg.sessionId) {
|
||||||
this.ssh.disconnect(msg.sessionId);
|
this.ssh.disconnect(msg.sessionId);
|
||||||
}
|
}
|
||||||
break;
|
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