fix(security): prevent shell injection in database changePassword mutations

This commit is contained in:
detail-app[bot] 2026-09-04 02:45:04 +00:00 committed by GitHub
parent 1572008cdf
commit e8fc88678a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 486 additions and 47 deletions

View File

@ -0,0 +1,329 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { apiCreateMariaDB } from "@dokploy/server/db/schema/mariadb";
import { apiCreateMongo } from "@dokploy/server/db/schema/mongo";
import { apiCreateMySql } from "@dokploy/server/db/schema/mysql";
import { apiCreatePostgres } from "@dokploy/server/db/schema/postgres";
import {
DATABASE_PASSWORD_REGEX,
DATABASE_USER_REGEX,
} from "@dokploy/server/db/schema/utils";
import {
escapeJsLiteral,
escapePostgresIdentifier,
escapeSqlLiteral,
execDockerArgv,
} from "@dokploy/server/utils/process/execAsync";
import { parse, quote } from "shell-quote";
import { afterAll, describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_dbcpwned_${process.pid}`;
afterAll(() => {
if (existsSync(MARK)) rmSync(MARK);
});
const TOUCH = `\`touch ${MARK}\``;
const DOLLAR = `$(touch ${MARK})`;
const safe = (argv: string[]): boolean => {
if (existsSync(MARK)) rmSync(MARK);
const command = quote(["docker", ...argv]).replace(/^docker /, ": ");
try {
execSync(command, { shell: "/bin/bash", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const mysqlArgv = (user: string, pass: string, rootPw = "rootpw") => {
const sql = `ALTER USER '${escapeSqlLiteral(user)}'@'%' IDENTIFIED BY '${escapeSqlLiteral(pass)}'; FLUSH PRIVILEGES;`;
return ["exec", "cid", "mysql", "-u", "root", `-p${rootPw}`, "-e", sql];
};
const mariadbArgv = (user: string, pass: string, rootPw = "rootpw") => {
const sql = `ALTER USER '${escapeSqlLiteral(user)}'@'%' IDENTIFIED BY '${escapeSqlLiteral(pass)}'; FLUSH PRIVILEGES;`;
return ["exec", "cid", "mariadb", "-u", "root", `-p${rootPw}`, "-e", sql];
};
const postgresArgv = (user: string, pass: string) => {
const sql = `ALTER USER "${escapePostgresIdentifier(user)}" WITH PASSWORD '${escapeSqlLiteral(pass)}';`;
return ["exec", "cid", "psql", "-U", user, "-d", "postgres", "-c", sql];
};
const mongoArgv = (user: string, pass: string, currentPw = "currentpw") => {
const evalExpr = `db.getSiblingDB('admin').changeUserPassword('${escapeJsLiteral(user)}', '${escapeJsLiteral(pass)}')`;
return [
"exec",
"cid",
"mongosh",
"-u",
user,
"-p",
currentPw,
"--authenticationDatabase",
"admin",
"--eval",
evalExpr,
];
};
const redisArgv = (currentPw: string, pass: string) => [
"exec",
"cid",
"redis-cli",
"-a",
currentPw,
"CONFIG",
"SET",
"requirepass",
pass,
];
describe("escape helpers", () => {
it("escapeSqlLiteral doubles single quotes", () => {
expect(escapeSqlLiteral("safe")).toBe("safe");
expect(escapeSqlLiteral("O'Brien")).toBe("O''Brien");
expect(escapeSqlLiteral("a'b'c")).toBe("a''b''c");
expect(escapeSqlLiteral("no quotes here")).toBe("no quotes here");
});
it("escapePostgresIdentifier doubles double quotes", () => {
expect(escapePostgresIdentifier("safe")).toBe("safe");
expect(escapePostgresIdentifier('a"b')).toBe('a""b');
expect(escapePostgresIdentifier('a"b"c')).toBe('a""b""c');
expect(escapePostgresIdentifier("no double quotes")).toBe(
"no double quotes",
);
});
it("escapeJsLiteral escapes backslashes first then single quotes", () => {
expect(escapeJsLiteral("safe")).toBe("safe");
expect(escapeJsLiteral("a'b")).toBe("a\\'b");
expect(escapeJsLiteral("a\\b")).toBe("a\\\\b");
expect(escapeJsLiteral("a\\'b")).toBe("a\\\\\\'b");
expect(escapeJsLiteral("a'b\\c")).toBe("a\\'b\\\\c");
});
});
describe("DATABASE_PASSWORD_REGEX blocks shell-dangerous characters", () => {
it("rejects backticks (command substitution)", () => {
expect(DATABASE_PASSWORD_REGEX.test("a`b")).toBe(false);
expect(DATABASE_PASSWORD_REGEX.test("`touch /tmp/x`")).toBe(false);
});
it("rejects other shell metacharacters", () => {
expect(DATABASE_PASSWORD_REGEX.test("a$b")).toBe(false);
expect(DATABASE_PASSWORD_REGEX.test("a'b")).toBe(false);
expect(DATABASE_PASSWORD_REGEX.test('a"b')).toBe(false);
expect(DATABASE_PASSWORD_REGEX.test("a!b")).toBe(false);
expect(DATABASE_PASSWORD_REGEX.test("a b")).toBe(false);
expect(DATABASE_PASSWORD_REGEX.test("a\\b")).toBe(false);
});
it("accepts safe passwords", () => {
expect(DATABASE_PASSWORD_REGEX.test("SafePass123")).toBe(true);
expect(DATABASE_PASSWORD_REGEX.test("P@ss#w0rd%")).toBe(true);
expect(DATABASE_PASSWORD_REGEX.test("a~b")).toBe(true);
expect(DATABASE_PASSWORD_REGEX.test("a^b&c*d")).toBe(true);
});
});
describe("DATABASE_USER_REGEX restricts to safe characters", () => {
it("accepts letters, numbers, underscores and hyphens", () => {
expect(DATABASE_USER_REGEX.test("myuser")).toBe(true);
expect(DATABASE_USER_REGEX.test("my_user")).toBe(true);
expect(DATABASE_USER_REGEX.test("my-user")).toBe(true);
expect(DATABASE_USER_REGEX.test("MyUser123")).toBe(true);
expect(DATABASE_USER_REGEX.test("root")).toBe(true);
});
it("rejects shell metacharacters", () => {
expect(DATABASE_USER_REGEX.test("my`user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my$user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my;user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my|user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my&user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my(user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my.user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my user")).toBe(false);
expect(DATABASE_USER_REGEX.test("my'user")).toBe(false);
expect(DATABASE_USER_REGEX.test('my"user')).toBe(false);
expect(DATABASE_USER_REGEX.test("my!user")).toBe(false);
});
it("rejects empty strings", () => {
expect(DATABASE_USER_REGEX.test("")).toBe(false);
});
});
describe("database create schema rejects dangerous usernames", () => {
const baseInput = {
name: "test-service",
databaseName: "testdb",
databasePassword: "SafePass123",
databaseRootPassword: "RootPass123",
environmentId: "env-1",
dockerImage: "mysql:8",
};
it("mysql rejects backtick in databaseUser", () => {
const r = apiCreateMySql.safeParse({
...baseInput,
databaseUser: "my`touch /tmp/x`user",
});
expect(r.success).toBe(false);
});
it("mariadb rejects backtick in databaseUser", () => {
const r = apiCreateMariaDB.safeParse({
...baseInput,
databaseUser: "my`touch /tmp/x`user",
});
expect(r.success).toBe(false);
});
it("postgres rejects backtick in databaseUser", () => {
const r = apiCreatePostgres.safeParse({
...baseInput,
databaseUser: "my`touch /tmp/x`user",
});
expect(r.success).toBe(false);
});
it("mongo rejects backtick in databaseUser", () => {
const r = apiCreateMongo.safeParse({
...baseInput,
databaseUser: "my`touch /tmp/x`user",
replicaSets: false,
});
expect(r.success).toBe(false);
});
it("all accept a safe username", () => {
expect(
apiCreateMySql.safeParse({ ...baseInput, databaseUser: "myuser" })
.success,
).toBe(true);
expect(
apiCreateMariaDB.safeParse({ ...baseInput, databaseUser: "my-user" })
.success,
).toBe(true);
expect(
apiCreatePostgres.safeParse({ ...baseInput, databaseUser: "my_user" })
.success,
).toBe(true);
expect(
apiCreateMongo.safeParse({
...baseInput,
databaseUser: "myuser123",
replicaSets: false,
}).success,
).toBe(true);
});
});
describe("remote changePassword command is not injectable", () => {
const cases: Array<[string, (u: string, p: string) => string[]]> = [
["mysql (user)", (u, p) => mysqlArgv(u, p)],
["mysql (root)", (u, p) => mysqlArgv("root", p, u)],
["mariadb (user)", (u, p) => mariadbArgv(u, p)],
["mariadb (root)", (u, p) => mariadbArgv("root", p, u)],
["postgres (user as argv)", (u, p) => postgresArgv(u, p)],
["postgres (user in sql)", (u, p) => postgresArgv(u, p)],
["mongo (user in eval)", (u, p) => mongoArgv(u, p)],
["mongo (pass in eval)", (_u, p) => mongoArgv("safeuser", p)],
["redis (current pass as argv)", (u, p) => redisArgv(u, p)],
["redis (new pass as argv)", (_u, p) => redisArgv("currentpw", p)],
];
for (const [label, build] of cases) {
it(`${label} neutralizes backtick payloads`, () => {
expect(safe(build(`a${TOUCH}b`, "pw"))).toBe(true);
expect(safe(build("user", `a${TOUCH}b`))).toBe(true);
});
it(`${label} neutralizes $() payloads`, () => {
expect(safe(build(`a${DOLLAR}b`, "pw"))).toBe(true);
expect(safe(build("user", `a${DOLLAR}b`))).toBe(true);
});
}
it("remote command preserves arguments intact (round-trip)", () => {
const argv = mysqlArgv("myuser", "MyP@ss123");
const cmd = quote(["docker", ...argv]);
const parsed = parse(cmd);
expect(parsed[0]).toBe("docker");
expect(parsed[7]).toBe("-e");
expect(parsed[8]).toBe(
"ALTER USER 'myuser'@'%' IDENTIFIED BY 'MyP@ss123'; FLUSH PRIVILEGES;",
);
});
it("postgres remote command preserves user identifier and password", () => {
const argv = postgresArgv("myuser", "MyP@ss123");
const cmd = quote(["docker", ...argv]);
const parsed = parse(cmd);
expect(parsed[5]).toBe("myuser");
expect(parsed[9]).toBe("ALTER USER \"myuser\" WITH PASSWORD 'MyP@ss123';");
});
it("mongo remote command preserves eval expression", () => {
const argv = mongoArgv("myuser", "MyP@ss123");
const cmd = quote(["docker", ...argv]);
const parsed = parse(cmd);
const evalArg = parsed[parsed.length - 1];
expect(evalArg).toBe(
"db.getSiblingDB('admin').changeUserPassword('myuser', 'MyP@ss123')",
);
});
});
describe("local changePassword execution uses no shell", () => {
it("backtick payload in local argv does not execute", async () => {
if (existsSync(MARK)) rmSync(MARK);
try {
await execDockerArgv(null, [
"exec",
"no-such-container",
"echo",
`a${TOUCH}b`,
]);
} catch {
// docker may be absent or container not found — both are fine
}
expect(existsSync(MARK)).toBe(false);
if (existsSync(MARK)) rmSync(MARK);
});
it("undefined serverId still routes to execFile (no shell)", async () => {
if (existsSync(MARK)) rmSync(MARK);
try {
await execDockerArgv(undefined, [
"exec",
"no-such-container",
"echo",
`a${TOUCH}b`,
]);
} catch {
// expected
}
expect(existsSync(MARK)).toBe(false);
if (existsSync(MARK)) rmSync(MARK);
});
});
describe("raw interpolation (old bug) is detectable by this harness", () => {
it("would fire when a backtick payload is NOT shell-quoted", () => {
if (existsSync(MARK)) rmSync(MARK);
const payload = `a${TOUCH}b`;
const sql = `ALTER USER '${payload}'@'%' IDENTIFIED BY 'pw';`;
const unsafe = `: exec cid mysql -u root -ppass -e "${sql}"`;
try {
execSync(unsafe, { shell: "/bin/bash", stdio: "ignore" });
} catch {}
expect(existsSync(MARK)).toBe(true);
if (existsSync(MARK)) rmSync(MARK);
});
});

View File

@ -3,8 +3,8 @@ import {
createMariadb,
createMount,
deployMariadb,
execAsync,
execAsyncRemote,
escapeSqlLiteral,
execDockerArgv,
findBackupsByDbId,
findEnvironmentById,
findMariadbById,
@ -420,7 +420,17 @@ export const mariadbRouter = createTRPCRouter({
const targetUser = type === "root" ? "root" : databaseUser;
const command = `docker exec ${container.Id} mariadb -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"`;
const sql = `ALTER USER '${escapeSqlLiteral(targetUser)}'@'%' IDENTIFIED BY '${escapeSqlLiteral(password)}'; FLUSH PRIVILEGES;`;
const argv = [
"exec",
container.Id,
"mariadb",
"-u",
"root",
`-p${databaseRootPassword}`,
"-e",
sql,
];
await db.transaction(async (tx) => {
const setData =
@ -432,11 +442,7 @@ export const mariadbRouter = createTRPCRouter({
.set(setData)
.where(eq(mariadbTable.mariadbId, mariadbId));
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command, { shell: "/bin/bash" });
}
await execDockerArgv(serverId, argv);
});
await audit(ctx, {

View File

@ -3,8 +3,8 @@ import {
createMongo,
createMount,
deployMongo,
execAsync,
execAsyncRemote,
escapeJsLiteral,
execDockerArgv,
findBackupsByDbId,
findEnvironmentById,
findMongoById,
@ -439,7 +439,20 @@ export const mongoRouter = createTRPCRouter({
});
}
const command = `docker exec ${container.Id} mongosh -u '${databaseUser}' -p '${databasePassword}' --authenticationDatabase admin --eval "db.getSiblingDB('admin').changeUserPassword('${databaseUser}', '${password}')"`;
const evalExpr = `db.getSiblingDB('admin').changeUserPassword('${escapeJsLiteral(databaseUser)}', '${escapeJsLiteral(password)}')`;
const argv = [
"exec",
container.Id,
"mongosh",
"-u",
databaseUser,
"-p",
databasePassword,
"--authenticationDatabase",
"admin",
"--eval",
evalExpr,
];
await db.transaction(async (tx) => {
await tx
@ -447,11 +460,7 @@ export const mongoRouter = createTRPCRouter({
.set({ databasePassword: password })
.where(eq(mongoTable.mongoId, mongoId));
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command, { shell: "/bin/bash" });
}
await execDockerArgv(serverId, argv);
});
await audit(ctx, {

View File

@ -3,8 +3,8 @@ import {
createMount,
createMysql,
deployMySql,
execAsync,
execAsyncRemote,
escapeSqlLiteral,
execDockerArgv,
findBackupsByDbId,
findEnvironmentById,
findMySqlById,
@ -438,7 +438,17 @@ export const mysqlRouter = createTRPCRouter({
const targetUser = type === "root" ? "root" : databaseUser;
const command = `docker exec ${container.Id} mysql -u root -p'${databaseRootPassword}' -e "ALTER USER '${targetUser}'@'%' IDENTIFIED BY '${password}'; FLUSH PRIVILEGES;"`;
const sql = `ALTER USER '${escapeSqlLiteral(targetUser)}'@'%' IDENTIFIED BY '${escapeSqlLiteral(password)}'; FLUSH PRIVILEGES;`;
const argv = [
"exec",
container.Id,
"mysql",
"-u",
"root",
`-p${databaseRootPassword}`,
"-e",
sql,
];
await db.transaction(async (tx) => {
const setData =
@ -450,11 +460,7 @@ export const mysqlRouter = createTRPCRouter({
.set(setData)
.where(eq(mysqlTable.mysqlId, mysqlId));
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command, { shell: "/bin/bash" });
}
await execDockerArgv(serverId, argv);
});
await audit(ctx, {

View File

@ -3,8 +3,9 @@ import {
createMount,
createPostgres,
deployPostgres,
execAsync,
execAsyncRemote,
escapePostgresIdentifier,
escapeSqlLiteral,
execDockerArgv,
findBackupsByDbId,
findEnvironmentById,
findPostgresById,
@ -445,7 +446,18 @@ export const postgresRouter = createTRPCRouter({
});
}
const command = `docker exec ${container.Id} psql -U ${databaseUser} -d postgres -c "ALTER USER \\"${databaseUser}\\" WITH PASSWORD '${password}';"`;
const sql = `ALTER USER "${escapePostgresIdentifier(databaseUser)}" WITH PASSWORD '${escapeSqlLiteral(password)}';`;
const argv = [
"exec",
container.Id,
"psql",
"-U",
databaseUser,
"-d",
"postgres",
"-c",
sql,
];
await db.transaction(async (tx) => {
await tx
@ -453,11 +465,7 @@ export const postgresRouter = createTRPCRouter({
.set({ databasePassword: password })
.where(eq(postgresTable.postgresId, postgresId));
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command, { shell: "/bin/bash" });
}
await execDockerArgv(serverId, argv);
});
await audit(ctx, {

View File

@ -3,8 +3,7 @@ import {
createMount,
createRedis,
deployRedis,
execAsync,
execAsyncRemote,
execDockerArgv,
findEnvironmentById,
findProjectById,
findRedisById,
@ -426,7 +425,17 @@ export const redisRouter = createTRPCRouter({
});
}
const command = `docker exec ${container.Id} redis-cli -a '${databasePassword}' CONFIG SET requirepass '${password}'`;
const argv = [
"exec",
container.Id,
"redis-cli",
"-a",
databasePassword,
"CONFIG",
"SET",
"requirepass",
password,
];
await db.transaction(async (tx) => {
await tx
@ -434,11 +443,7 @@ export const redisRouter = createTRPCRouter({
.set({ databasePassword: password })
.where(eq(redisTable.redisId, redisId));
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command, { shell: "/bin/bash" });
}
await execDockerArgv(serverId, argv);
});
await audit(ctx, {

View File

@ -40,6 +40,8 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
DATABASE_USER_MESSAGE,
DATABASE_USER_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@ -125,7 +127,9 @@ const createSchema = createInsertSchema(mariadb, {
.optional(),
createdAt: z.string(),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
databaseUser: z.string().min(1).regex(DATABASE_USER_REGEX, {
message: DATABASE_USER_MESSAGE,
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),

View File

@ -40,6 +40,8 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
DATABASE_USER_MESSAGE,
DATABASE_USER_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@ -124,7 +126,9 @@ const createSchema = createInsertSchema(mongo, {
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseUser: z.string().min(1),
databaseUser: z.string().min(1).regex(DATABASE_USER_REGEX, {
message: DATABASE_USER_MESSAGE,
}),
dockerImage: z.string().default("mongo:15"),
command: z.string().optional(),
args: z.array(z.string()).optional(),

View File

@ -40,6 +40,8 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
DATABASE_USER_MESSAGE,
DATABASE_USER_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@ -123,7 +125,9 @@ const createSchema = createInsertSchema(mysql, {
createdAt: z.string(),
name: z.string().min(1),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
databaseUser: z.string().min(1).regex(DATABASE_USER_REGEX, {
message: DATABASE_USER_MESSAGE,
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),

View File

@ -40,6 +40,8 @@ import {
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
DATABASE_USER_MESSAGE,
DATABASE_USER_REGEX,
encryptedText,
generateAppName,
} from "./utils";
@ -125,7 +127,9 @@ const createSchema = createInsertSchema(postgres, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
databaseUser: z.string().min(1).regex(DATABASE_USER_REGEX, {
message: DATABASE_USER_MESSAGE,
}),
dockerImage: z.string().default("postgres:18"),
command: z.string().optional(),
args: z.array(z.string()).optional(),

View File

@ -47,12 +47,26 @@ export const VOLUME_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const VOLUME_NAME_MESSAGE =
"Volume name must start with a letter or number and contain only letters, numbers, dots, underscores and hyphens";
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
/**
* Database password: blocks shell-dangerous characters like $ ! ' " \ / ` and
* spaces. The backtick is intentionally excluded it is a live
* command-substitution metacharacter inside bash double quotes.
*/
export const DATABASE_PASSWORD_REGEX =
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~]*$/;
export const DATABASE_PASSWORD_MESSAGE =
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility";
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / ` and space characters for database compatibility";
/**
* Database username: only letters, numbers, underscores and hyphens.
* Database usernames are interpolated into shell commands and SQL/JS
* literals, so shell metacharacters must never be permitted.
*/
export const DATABASE_USER_REGEX = /^[A-Za-z0-9_-]+$/;
export const DATABASE_USER_MESSAGE =
"Database username can only contain letters, numbers, underscores and hyphens";
export const generateAppName = (type: string) => {
const verb = faker.hacker.verb().replace(/ /g, "-");

View File

@ -1,6 +1,7 @@
import { exec, execFile } from "node:child_process";
import util from "node:util";
import { findServerById } from "@dokploy/server/services/server";
import { quote } from "shell-quote";
import { Client } from "ssh2";
import { ExecError } from "./ExecError";
@ -324,3 +325,48 @@ export const writeFileRemote = async (
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
/**
* Escape a string for use as a SQL single-quoted string literal by doubling
* single quotes (`'` -> `''`). This is the standard SQL escaping for string
* literals wrapped in single quotes.
*/
export const escapeSqlLiteral = (str: string): string =>
str.replace(/'/g, "''");
/**
* Escape a string for use as a PostgreSQL double-quoted identifier by doubling
* double quotes (`"` -> `""`).
*/
export const escapePostgresIdentifier = (str: string): string =>
str.replace(/"/g, '""');
/**
* Escape a string for embedding inside a JavaScript single-quoted string
* literal (e.g. for `mongosh --eval`). Backslashes are doubled first, then
* single quotes are escaped.
*/
export const escapeJsLiteral = (str: string): string =>
str.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
/**
* Execute `docker` with the given argv without a shell (local) or with proper
* shell quoting (remote SSH). Each argv element is treated as a single
* argument never as shell source code so attacker-controlled values
* cannot inject commands.
*
* @param serverId - When set, the command is run on the remote server over
* SSH (arguments are shell-quoted via `shell-quote`'s `quote()`). When falsy,
* it is executed locally via `execFile` (no shell at all).
* @param argv - The `docker` arguments (e.g. `["exec", id, "mysql", ...]`).
*/
export const execDockerArgv = async (
serverId: string | null | undefined,
argv: string[],
): Promise<{ stdout: string; stderr: string }> => {
if (serverId) {
const command = quote(["docker", ...argv]);
return execAsyncRemote(serverId, command);
}
return execFileAsync("docker", argv);
};