From b24202e69b244f0ece8d2f56e99cad9bb5e1a248 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 22:45:31 -0600 Subject: [PATCH] fix(security): escape dockerImage in database service remote docker pull The deploy functions for postgres/mysql/mariadb/mongo/redis/libsql interpolated the user-settable dockerImage field unquoted into 'docker pull ${dockerImage}' executed via execAsyncRemote (SSH) on the remote server path. Now passed through shell-quote. The local path already used pullImage() (execFile-based) and is unaffected. Closes GHSA-6jrh-8qmg-jj3p --- .../db-service-dockerimage-injection.test.ts | 41 +++++++++++++++++++ packages/server/src/services/libsql.ts | 3 +- packages/server/src/services/mariadb.ts | 3 +- packages/server/src/services/mongo.ts | 3 +- packages/server/src/services/mysql.ts | 3 +- packages/server/src/services/postgres.ts | 3 +- packages/server/src/services/redis.ts | 3 +- 7 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 apps/dokploy/__test__/deploy/db-service-dockerimage-injection.test.ts diff --git a/apps/dokploy/__test__/deploy/db-service-dockerimage-injection.test.ts b/apps/dokploy/__test__/deploy/db-service-dockerimage-injection.test.ts new file mode 100644 index 000000000..6d64cb3a4 --- /dev/null +++ b/apps/dokploy/__test__/deploy/db-service-dockerimage-injection.test.ts @@ -0,0 +1,41 @@ +import { execSync } from "node:child_process"; +import { existsSync, rmSync } from "node:fs"; +import { parse, quote } from "shell-quote"; +import { describe, expect, it } from "vitest"; + +// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql) +// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote) +// path. `docker` is replaced by `:` so only the injection surface is exercised. +const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`; + +const PAYLOADS = [ + "$(touch %MARK%)", + "`touch %MARK%`", + "redis:7; touch %MARK%", + "redis:7 && touch %MARK%", + "redis:7 | touch %MARK%", +]; + +describe("database service dockerImage command injection", () => { + it("does not execute injected commands from dockerImage", () => { + for (const template of PAYLOADS) { + if (existsSync(MARK)) rmSync(MARK); + const dockerImage = template.replace("%MARK%", MARK); + const command = `: pull ${quote([dockerImage])}`; + try { + execSync(command, { shell: "/bin/sh", stdio: "ignore" }); + } catch {} + expect(existsSync(MARK)).toBe(false); + } + if (existsSync(MARK)) rmSync(MARK); + }); + + it("keeps a legitimate image tag intact", () => { + expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([ + "postgres:16.4-alpine", + ]); + expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([ + "ghcr.io/org/db:latest", + ]); + }); +}); diff --git a/packages/server/src/services/libsql.ts b/packages/server/src/services/libsql.ts index dd2b82667..5a362789a 100644 --- a/packages/server/src/services/libsql.ts +++ b/packages/server/src/services/libsql.ts @@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { eq, getTableColumns } from "drizzle-orm"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { validUniqueServerAppName } from "./project"; @@ -140,7 +141,7 @@ export const deployLibsql = async ( if (libsql.serverId) { await execAsyncRemote( libsql.serverId, - `docker pull ${libsql.dockerImage}`, + `docker pull ${quote([libsql.dockerImage])}`, onData, ); } else { diff --git a/packages/server/src/services/mariadb.ts b/packages/server/src/services/mariadb.ts index 189ab39ad..54ffb34ba 100644 --- a/packages/server/src/services/mariadb.ts +++ b/packages/server/src/services/mariadb.ts @@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { eq, getTableColumns } from "drizzle-orm"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { validUniqueServerAppName } from "./project"; @@ -145,7 +146,7 @@ export const deployMariadb = async ( if (mariadb.serverId) { await execAsyncRemote( mariadb.serverId, - `docker pull ${mariadb.dockerImage}`, + `docker pull ${quote([mariadb.dockerImage])}`, onData, ); } else { diff --git a/packages/server/src/services/mongo.ts b/packages/server/src/services/mongo.ts index ad47cf04a..c6ff19f1e 100644 --- a/packages/server/src/services/mongo.ts +++ b/packages/server/src/services/mongo.ts @@ -12,6 +12,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { eq, getTableColumns } from "drizzle-orm"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { validUniqueServerAppName } from "./project"; @@ -160,7 +161,7 @@ export const deployMongo = async ( if (mongo.serverId) { await execAsyncRemote( mongo.serverId, - `docker pull ${mongo.dockerImage}`, + `docker pull ${quote([mongo.dockerImage])}`, onData, ); } else { diff --git a/packages/server/src/services/mysql.ts b/packages/server/src/services/mysql.ts index 974f72f19..288c6fc28 100644 --- a/packages/server/src/services/mysql.ts +++ b/packages/server/src/services/mysql.ts @@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { eq, getTableColumns } from "drizzle-orm"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { validUniqueServerAppName } from "./project"; @@ -143,7 +144,7 @@ export const deployMySql = async ( if (mysql.serverId) { await execAsyncRemote( mysql.serverId, - `docker pull ${mysql.dockerImage}`, + `docker pull ${quote([mysql.dockerImage])}`, onData, ); } else { diff --git a/packages/server/src/services/postgres.ts b/packages/server/src/services/postgres.ts index d4dddfdaf..3a9754125 100644 --- a/packages/server/src/services/postgres.ts +++ b/packages/server/src/services/postgres.ts @@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { eq, getTableColumns } from "drizzle-orm"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { validUniqueServerAppName } from "./project"; @@ -155,7 +156,7 @@ export const deployPostgres = async ( if (postgres.serverId) { await execAsyncRemote( postgres.serverId, - `docker pull ${postgres.dockerImage}`, + `docker pull ${quote([postgres.dockerImage])}`, onData, ); } else { diff --git a/packages/server/src/services/redis.ts b/packages/server/src/services/redis.ts index 99f4dc853..da2822b13 100644 --- a/packages/server/src/services/redis.ts +++ b/packages/server/src/services/redis.ts @@ -10,6 +10,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; +import { quote } from "shell-quote"; import type { z } from "zod"; import { validUniqueServerAppName } from "./project"; @@ -110,7 +111,7 @@ export const deployRedis = async ( if (redis.serverId) { await execAsyncRemote( redis.serverId, - `docker pull ${redis.dockerImage}`, + `docker pull ${quote([redis.dockerImage])}`, onData, ); } else {