diff --git a/apps/dokploy/__test__/deploy/db-changepassword-injection.test.ts b/apps/dokploy/__test__/deploy/db-changepassword-injection.test.ts new file mode 100644 index 000000000..01074e6e8 --- /dev/null +++ b/apps/dokploy/__test__/deploy/db-changepassword-injection.test.ts @@ -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); + }); +}); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 98932f8af..3bcc024cc 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -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, { diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index 42b410785..49c36667b 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -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, { diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index c315ec5d4..cd57554db 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -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, { diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 97e398123..2b4468eed 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -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, { diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index fb0d8301c..8dbf36f02 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -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, { diff --git a/packages/server/src/db/schema/mariadb.ts b/packages/server/src/db/schema/mariadb.ts index 48b48f5b6..dcc3480b4 100644 --- a/packages/server/src/db/schema/mariadb.ts +++ b/packages/server/src/db/schema/mariadb.ts @@ -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, }), diff --git a/packages/server/src/db/schema/mongo.ts b/packages/server/src/db/schema/mongo.ts index 5e91a72d1..50753a246 100644 --- a/packages/server/src/db/schema/mongo.ts +++ b/packages/server/src/db/schema/mongo.ts @@ -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(), diff --git a/packages/server/src/db/schema/mysql.ts b/packages/server/src/db/schema/mysql.ts index 3a58f5aad..ef775aa9f 100644 --- a/packages/server/src/db/schema/mysql.ts +++ b/packages/server/src/db/schema/mysql.ts @@ -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, }), diff --git a/packages/server/src/db/schema/postgres.ts b/packages/server/src/db/schema/postgres.ts index 067b83e43..b144e8ac9 100644 --- a/packages/server/src/db/schema/postgres.ts +++ b/packages/server/src/db/schema/postgres.ts @@ -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(), diff --git a/packages/server/src/db/schema/utils.ts b/packages/server/src/db/schema/utils.ts index 4bf140738..61b7f21dd 100644 --- a/packages/server/src/db/schema/utils.ts +++ b/packages/server/src/db/schema/utils.ts @@ -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, "-"); diff --git a/packages/server/src/utils/process/execAsync.ts b/packages/server/src/utils/process/execAsync.ts index 37b0b7f91..c73f95f66 100644 --- a/packages/server/src/utils/process/execAsync.ts +++ b/packages/server/src/utils/process/execAsync.ts @@ -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); +};