mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
fix(security): prevent shell injection in database changePassword mutations
This commit is contained in:
parent
1572008cdf
commit
e8fc88678a
329
apps/dokploy/__test__/deploy/db-changepassword-injection.test.ts
Normal file
329
apps/dokploy/__test__/deploy/db-changepassword-injection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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, {
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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, "-");
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user