Skip to content

Commit f33504b

Browse files
Merge pull request #478 from community-scripts/fix/312
fix: handle special characters in SSH password/passphrase (Fixes #312)
2 parents a52a897 + 4bc5f4d commit f33504b

File tree

3 files changed

+59
-31
lines changed

3 files changed

+59
-31
lines changed

src/app/_components/ServerForm.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,11 @@ export function ServerForm({
438438
{errors.password && (
439439
<p className="text-destructive mt-1 text-sm">{errors.password}</p>
440440
)}
441+
<p className="text-muted-foreground mt-1 text-xs">
442+
SSH key is recommended when possible. Special characters (e.g.{" "}
443+
<code className="rounded bg-muted px-0.5">{"{ } $ \" '"}</code>) are
444+
supported.
445+
</p>
441446
</div>
442447
)}
443448

src/server/ssh-execution-service.js

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { spawn } from 'child_process';
22
import { spawn as ptySpawn } from 'node-pty';
3-
import { existsSync } from 'fs';
3+
import { existsSync, writeFileSync, chmodSync, unlinkSync } from 'fs';
4+
import { join } from 'path';
5+
import { tmpdir } from 'os';
46

57

68
/**
@@ -194,26 +196,45 @@ class SSHExecutionService {
194196
*/
195197
async transferScriptsFolder(server, onData, onError) {
196198
const { ip, user, password, auth_type = 'password', ssh_key_passphrase, ssh_key_path, ssh_port = 22 } = server;
197-
199+
200+
const cleanupTempFile = (/** @type {string | null} */ tempPath) => {
201+
if (tempPath) {
202+
try {
203+
unlinkSync(tempPath);
204+
} catch (_) {
205+
// ignore
206+
}
207+
}
208+
};
209+
198210
return new Promise((resolve, reject) => {
211+
/** @type {string | null} */
212+
let tempPath = null;
199213
try {
200-
// Build rsync command based on authentication type
214+
// Build rsync command based on authentication type.
215+
// Use sshpass -f with a temp file so password/passphrase never go through the shell (safe for special chars like {, $, ").
201216
let rshCommand;
202217
if (auth_type === 'key') {
203218
if (!ssh_key_path || !existsSync(ssh_key_path)) {
204219
throw new Error('SSH key file not found');
205220
}
206-
221+
207222
if (ssh_key_passphrase) {
208-
rshCommand = `sshpass -P passphrase -p ${ssh_key_passphrase} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
223+
tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`);
224+
writeFileSync(tempPath, ssh_key_passphrase);
225+
chmodSync(tempPath, 0o600);
226+
rshCommand = `sshpass -P passphrase -f ${tempPath} ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
209227
} else {
210228
rshCommand = `ssh -i ${ssh_key_path} -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
211229
}
212230
} else {
213231
// Password authentication
214-
rshCommand = `sshpass -p ${password} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
232+
tempPath = join(tmpdir(), `sshpass-${process.pid}-${Date.now()}.tmp`);
233+
writeFileSync(tempPath, password ?? '');
234+
chmodSync(tempPath, 0o600);
235+
rshCommand = `sshpass -f ${tempPath} ssh -p ${ssh_port} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null`;
215236
}
216-
237+
217238
const rsyncCommand = spawn('rsync', [
218239
'-avz',
219240
'--delete',
@@ -226,31 +247,31 @@ class SSHExecutionService {
226247
stdio: ['pipe', 'pipe', 'pipe']
227248
});
228249

229-
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
230-
// Ensure proper UTF-8 encoding for ANSI colors
231-
const output = data.toString('utf8');
232-
onData(output);
233-
});
250+
rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => {
251+
const output = data.toString('utf8');
252+
onData(output);
253+
});
234254

235-
rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => {
236-
// Ensure proper UTF-8 encoding for ANSI colors
237-
const output = data.toString('utf8');
238-
onError(output);
239-
});
255+
rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => {
256+
const output = data.toString('utf8');
257+
onError(output);
258+
});
240259

241-
rsyncCommand.on('close', (code) => {
242-
if (code === 0) {
243-
resolve();
244-
} else {
245-
reject(new Error(`rsync failed with code ${code}`));
246-
}
247-
});
260+
rsyncCommand.on('close', (code) => {
261+
cleanupTempFile(tempPath);
262+
if (code === 0) {
263+
resolve();
264+
} else {
265+
reject(new Error(`rsync failed with code ${code}`));
266+
}
267+
});
248268

249-
rsyncCommand.on('error', (error) => {
250-
reject(error);
251-
});
252-
269+
rsyncCommand.on('error', (error) => {
270+
cleanupTempFile(tempPath);
271+
reject(error);
272+
});
253273
} catch (error) {
274+
cleanupTempFile(tempPath);
254275
reject(error);
255276
}
256277
});

src/server/ssh-service.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,16 +169,17 @@ class SSHService {
169169
const timeout = 10000;
170170
let resolved = false;
171171

172+
// Pass password via env so it is not embedded in the script (safe for special chars like {, $, ").
172173
const expectScript = `#!/usr/bin/expect -f
173174
set timeout 10
174175
spawn ssh -p ${ssh_port} -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS"
175176
expect {
176177
"password:" {
177-
send "${password}\r"
178+
send "$env(SSH_PASSWORD)\\r"
178179
exp_continue
179180
}
180181
"Password:" {
181-
send "${password}\r"
182+
send "$env(SSH_PASSWORD)\\r"
182183
exp_continue
183184
}
184185
"SSH_LOGIN_SUCCESS" {
@@ -193,7 +194,8 @@ expect {
193194
}`;
194195

195196
const expectCommand = spawn('expect', ['-c', expectScript], {
196-
stdio: ['pipe', 'pipe', 'pipe']
197+
stdio: ['pipe', 'pipe', 'pipe'],
198+
env: { ...process.env, SSH_PASSWORD: password ?? '' }
197199
});
198200

199201
const timer = setTimeout(() => {

0 commit comments

Comments
 (0)