Skip to content

Commit 36bb5a0

Browse files
committed
security: close cron + SQL-ident injection paths in backup/db tools
Two findings from the security review, both require adversarial input to reach (MCP caller controls the args), but the blast radius exceeds what the tool is supposed to allow: 1. **ssh_backup_schedule: cron injection via newline** The cron validator counted whitespace-separated tokens, so a value like "0 0 * * *\n* * * * * rm -rf ~" passed. shQuote preserves newlines inside single-quoted strings, so `printf '%s\n' ${shQuote(cronLine)}` installed a multi-line crontab. Fix: reject cron with any \r\n\t and any shell metacharacter ($, `), then split on spaces only. 2. **ssh_db_{query,list,dump,import}: SQL ident injection via database/user** `database` and `user` were shQuote'd for the shell but interpolated into SQL strings like `SHOW TABLES FROM 'name'` and `pg_database_size('name')`. A database name of `app'; DROP DATABASE x; --` shell-unquotes to a valid SQL injection payload. Fix: validate database/user against [A-Za-z0-9_][A-Za-z0-9_.-]{0,63} (the conservative intersection of MySQL/PG/Mongo identifier rules) at handler entry. +4 regression tests (651 -> 653 passing). No existing behavior changed for well-formed inputs.
1 parent 4ba9d85 commit 36bb5a0

4 files changed

Lines changed: 131 additions & 4 deletions

File tree

src/tools/backup-tools.js

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -647,9 +647,30 @@ export async function handleSshBackupSchedule({ getConnection, args }) {
647647
if (!VALID_TYPES.has(backup_type)) {
648648
return toMcp(fail('ssh_backup_schedule', `invalid backup_type: ${backup_type}`, { server }), { format });
649649
}
650-
if (!cron || typeof cron !== 'string' || cron.trim().split(/\s+/).length < 5) {
650+
if (!cron || typeof cron !== 'string') {
651651
return toMcp(fail('ssh_backup_schedule', 'cron is required (e.g. "0 2 * * *")', { server }), { format });
652652
}
653+
const cronTrimmed = cron.trim();
654+
// Reject embedded newlines/CR/tab -- cron must be a single line. Without this,
655+
// a caller could smuggle extra crontab entries via "0 0 * * *\nMALICIOUS\n".
656+
if (/[\r\n\t]/.test(cronTrimmed)) {
657+
return toMcp(fail('ssh_backup_schedule',
658+
'cron must be a single line (no newlines/tabs)', { server }), { format });
659+
}
660+
// Reject anything that looks like it came through a shell expansion break
661+
// (backticks, $() etc) even though shQuote would neutralize them --
662+
// defense in depth + clearer UX on malformed input.
663+
if (/[`$]/.test(cronTrimmed)) {
664+
return toMcp(fail('ssh_backup_schedule',
665+
'cron cannot contain shell metacharacters ($, `)', { server }), { format });
666+
}
667+
// A cron expression is 5 (or 6 with seconds) whitespace-separated time fields.
668+
// Split on literal space only -- newlines were already rejected above.
669+
const fields = cronTrimmed.split(/ +/);
670+
if (fields.length < 5) {
671+
return toMcp(fail('ssh_backup_schedule',
672+
'cron must have at least 5 time fields (e.g. "0 2 * * *")', { server }), { format });
673+
}
653674

654675
// Refuse to schedule if a password was passed -- writing it into crontab
655676
// would persist the secret in plaintext (readable to anyone who gains the
@@ -696,14 +717,14 @@ export async function handleSshBackupSchedule({ getConnection, args }) {
696717
// job can execute correctly; the URI encodes db/host/port, not credentials.
697718
const fullCmd = (cmdBundle.envPrefix || '') + cmdBundle.command;
698719
const marker = `# claude-code-ssh-backup:${backup_type}:${targetName}`;
699-
const cronLine = `${cron.trim()} ${fullCmd} ${marker}`;
720+
const cronLine = `${cronTrimmed} ${fullCmd} ${marker}`;
700721

701722
if (isPreview) {
702723
const plan = buildPlan({
703724
action: 'backup-schedule',
704725
target: `${server}:crontab`,
705726
effects: [
706-
`cron: \`${cron.trim()}\``,
727+
`cron: \`${cronTrimmed}\``,
707728
`backup_type: \`${backup_type}\``,
708729
backup_type === 'files'
709730
? `paths: ${(paths || []).map(p => `\`${p}\``).join(', ')}`
@@ -745,7 +766,7 @@ export async function handleSshBackupSchedule({ getConnection, args }) {
745766

746767
return toMcp(
747768
ok('ssh_backup_schedule', {
748-
cron: cron.trim(),
769+
cron: cronTrimmed,
749770
cron_line: cronLine,
750771
marker,
751772
backup_type,

src/tools/db-tools.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,32 @@ const DEFAULT_TIMEOUT_MS = 60_000;
2626
const DEFAULT_LIMIT = 1000;
2727
const MAX_ALLOWED_LIMIT = 100_000;
2828

29+
/**
30+
* Conservative SQL-identifier validator (database / user names).
31+
*
32+
* shQuote() is correct for SHELL quoting but a value like `app'; DROP DATABASE x; --`
33+
* shell-unquotes to a literal SQL string and becomes an INJECTED SQL token when the
34+
* outer `-e '<query>'` is parsed by mysql/psql. We don't render idents as SQL string
35+
* literals anywhere safe -- `SHOW TABLES FROM 'name'`, `pg_database_size('name')`,
36+
* and the parameterless `mysqldump <name>` all treat the value as an identifier.
37+
* So we require a syntactic subset that every mainstream DBMS allows for
38+
* database/role/schema/table names:
39+
* [A-Za-z0-9_][A-Za-z0-9_.-]{0,63}
40+
* Max 64 chars (MySQL cap is 64, PG is 63). `.` allowed so `schema.table` works for
41+
* mongodump --db / pg_database_size callers that pass schema-qualified names.
42+
* No spaces, no quotes, no semicolons, no backticks, no backslashes.
43+
*/
44+
const SQL_IDENT_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
45+
function isSafeSqlIdent(s) {
46+
return typeof s === 'string' && SQL_IDENT_RE.test(s);
47+
}
48+
function rejectBadIdent(tool, field, value, { server, format }) {
49+
return toMcp(
50+
fail(tool, `${field} contains unsafe characters (must match [A-Za-z0-9_][A-Za-z0-9_.-]{0,63})`, { server }),
51+
{ format },
52+
);
53+
}
54+
2955
/**
3056
* Assemble a MongoDB URI for the `mongo` family so we can hand it to mongosh
3157
* via env var instead of argv. Defaults to localhost:27017 because the SSH
@@ -284,6 +310,12 @@ export async function handleSshDbQuery({ getConnection, args }) {
284310
if (!query || typeof query !== 'string') {
285311
return toMcp(fail('ssh_db_query', 'query is required'), { format });
286312
}
313+
if (database != null && !isSafeSqlIdent(database)) {
314+
return rejectBadIdent('ssh_db_query', 'database', database, { server, format });
315+
}
316+
if (user != null && !isSafeSqlIdent(user)) {
317+
return rejectBadIdent('ssh_db_query', 'user', user, { server, format });
318+
}
287319

288320
const cappedLimit = safeInt(limit, { min: 1, max: MAX_ALLOWED_LIMIT, fallback: DEFAULT_LIMIT });
289321

@@ -428,6 +460,12 @@ export async function handleSshDbList({ getConnection, args }) {
428460
if (!['mysql', 'postgresql', 'mongodb'].includes(db_type)) {
429461
return toMcp(fail('ssh_db_list', `unsupported db_type: ${db_type}`), { format });
430462
}
463+
if (database != null && !isSafeSqlIdent(database)) {
464+
return rejectBadIdent('ssh_db_list', 'database', database, { server, format });
465+
}
466+
if (user != null && !isSafeSqlIdent(user)) {
467+
return rejectBadIdent('ssh_db_list', 'user', user, { server, format });
468+
}
431469

432470
let cmd;
433471
if (db_type === 'mysql') cmd = buildMySqlListCommand({ database, user });
@@ -500,6 +538,12 @@ export async function handleSshDbDump({ getConnection, args }) {
500538
return toMcp(fail('ssh_db_dump', `unsupported db_type: ${db_type}`), { format });
501539
}
502540
if (!database) return toMcp(fail('ssh_db_dump', 'database is required'), { format });
541+
if (!isSafeSqlIdent(database)) {
542+
return rejectBadIdent('ssh_db_dump', 'database', database, { server, format });
543+
}
544+
if (user != null && !isSafeSqlIdent(user)) {
545+
return rejectBadIdent('ssh_db_dump', 'user', user, { server, format });
546+
}
503547

504548
const outPath = output_path ||
505549
`/tmp/${database}-${Date.now()}.${db_type === 'mongodb' ? 'archive' : 'sql'}${gzip ? '.gz' : ''}`;
@@ -593,6 +637,12 @@ export async function handleSshDbImport({ getConnection, args }) {
593637
return toMcp(fail('ssh_db_import', `unsupported db_type: ${db_type}`), { format });
594638
}
595639
if (!database) return toMcp(fail('ssh_db_import', 'database is required'), { format });
640+
if (!isSafeSqlIdent(database)) {
641+
return rejectBadIdent('ssh_db_import', 'database', database, { server, format });
642+
}
643+
if (user != null && !isSafeSqlIdent(user)) {
644+
return rejectBadIdent('ssh_db_import', 'user', user, { server, format });
645+
}
596646
if (!input_path) return toMcp(fail('ssh_db_import', 'input_path is required'), { format });
597647

598648
if (isPreview) {

tests/test-backup-tools.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,33 @@ await test('backup_schedule: preview shows cron plan', async () => {
256256
assert(parsed.data.plan.action.includes('schedule'));
257257
});
258258

259+
await test('backup_schedule: rejects cron with embedded newline (injection guard)', async () => {
260+
const r = await handleSshBackupSchedule({
261+
getConnection: async () => { throw new Error('should not reach connection'); },
262+
args: {
263+
server: 's',
264+
cron: '0 0 * * *\n* * * * * rm -rf ~',
265+
backup_type: 'mysql', database: 'app',
266+
preview: true, format: 'json',
267+
},
268+
});
269+
assert.strictEqual(r.isError, true);
270+
const parsed = JSON.parse(r.content[0].text);
271+
assert(/single line|newline/i.test(parsed.error), `expected newline rejection, got: ${parsed.error}`);
272+
});
273+
274+
await test('backup_schedule: rejects cron with shell metacharacters', async () => {
275+
for (const bad of ['0 0 * * * `id`', '0 0 * * * $(whoami)']) {
276+
const r = await handleSshBackupSchedule({
277+
getConnection: async () => { throw new Error('should not reach connection'); },
278+
args: { server: 's', cron: bad, backup_type: 'mysql', database: 'app', preview: true, format: 'json' },
279+
});
280+
assert.strictEqual(r.isError, true, `expected fail for ${JSON.stringify(bad)}`);
281+
const parsed = JSON.parse(r.content[0].text);
282+
assert(/shell metacharacters|\$|`/.test(parsed.error), `expected metachar rejection, got: ${parsed.error}`);
283+
}
284+
});
285+
259286
await test('backup_schedule: refuses password arg for DB backups (no plaintext secret in crontab)', async () => {
260287
for (const dbType of ['mysql', 'postgresql', 'mongodb']) {
261288
const r = await handleSshBackupSchedule({

tests/test-db-tools.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,35 @@ await test('ssh_db_query: isSafeSelect rejection -> structured fail, NO remote c
126126
assert.strictEqual(r.isError, true);
127127
});
128128

129+
await test('ssh_db_query: rejects database/user names with SQL metacharacters (injection guard)', async () => {
130+
for (const bad of ['app\'; DROP DATABASE x; --', 'app; DROP', 'a b', 'a`b', 'a\\b']) {
131+
const r = await handleSshDbQuery({
132+
getConnection: async () => { throw new Error('must not connect'); },
133+
args: { server: 's', db_type: 'mysql', database: bad, query: 'SELECT 1', format: 'json' },
134+
});
135+
assert.strictEqual(r.isError, true, `expected fail for database=${JSON.stringify(bad)}`);
136+
const parsed = JSON.parse(r.content[0].text);
137+
assert(parsed.error.includes('unsafe characters'),
138+
`expected 'unsafe characters' in error, got: ${parsed.error}`);
139+
}
140+
// user field should be guarded too
141+
const r2 = await handleSshDbQuery({
142+
getConnection: async () => { throw new Error('must not connect'); },
143+
args: { server: 's', db_type: 'mysql', query: 'SELECT 1', user: 'u\'; DROP', format: 'json' },
144+
});
145+
assert.strictEqual(r2.isError, true);
146+
});
147+
148+
await test('ssh_db_dump: rejects database names with metacharacters', async () => {
149+
const r = await handleSshDbDump({
150+
getConnection: async () => { throw new Error('must not connect'); },
151+
args: { server: 's', db_type: 'mysql', database: 'x\'; rm -rf /', format: 'json', preview: true },
152+
});
153+
assert.strictEqual(r.isError, true);
154+
const parsed = JSON.parse(r.content[0].text);
155+
assert(parsed.error.includes('unsafe characters'));
156+
});
157+
129158
await test('ssh_db_query: `SELECT deleted_at FROM t` is accepted (old impl would falsely reject)', async () => {
130159
const client = new FakeClient({ script: () => ({ stdout: 'deleted_at\n2024-01-01\n', code: 0 }) });
131160
const r = await handleSshDbQuery({

0 commit comments

Comments
 (0)