From cc01a1df3c25b427d692594bb97703c03573b2bd Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:45:42 +0000 Subject: [PATCH] fix(builders): prevent shell injection from railpack/heroku custom versions --- .../deploy/railpack-version-schema.test.ts | 142 +++++++++++ .../deploy/railpack.injection.test.ts | 235 ++++++++++++++++++ packages/server/src/db/schema/application.ts | 15 +- packages/server/src/utils/builders/heroku.ts | 3 +- .../server/src/utils/builders/railpack.ts | 4 +- 5 files changed, 394 insertions(+), 5 deletions(-) create mode 100644 apps/dokploy/__test__/deploy/railpack-version-schema.test.ts create mode 100644 apps/dokploy/__test__/deploy/railpack.injection.test.ts diff --git a/apps/dokploy/__test__/deploy/railpack-version-schema.test.ts b/apps/dokploy/__test__/deploy/railpack-version-schema.test.ts new file mode 100644 index 000000000..babb54b23 --- /dev/null +++ b/apps/dokploy/__test__/deploy/railpack-version-schema.test.ts @@ -0,0 +1,142 @@ +import { + apiSaveBuildType, + apiUpdateApplication, +} from "@dokploy/server/db/schema/application"; +import { describe, expect, it } from "vitest"; + +// Regression tests for the server-side validation of the user-controlled +// `railpackVersion` and `herokuVersion` fields. Both are spliced into a bash +// build script and must be constrained to version-shaped strings (and keep +// accepting null/empty so the UI save flow for non-railpack/heroku build types +// is not broken). See railpack.injection.test.ts for the sink-level coverage. + +const validRailpack = { + applicationId: "app-1", + buildType: "railpack" as const, + dockerfile: "", + dockerContextPath: "", + dockerBuildStage: "", + herokuVersion: null, + railpackVersion: "0.15.4", +}; + +const validHeroku = { + applicationId: "app-1", + buildType: "heroku_buildpacks" as const, + dockerfile: "", + dockerContextPath: "", + dockerBuildStage: "", + herokuVersion: "24", + railpackVersion: null, +}; + +describe("apiSaveBuildType railpackVersion validation", () => { + it.each([ + ["0.15.4"], + ["1.2.3"], + ["0.15.4-rc.1"], + ["0.15.4-alpha-beta.gamma"], + ])("accepts valid railpack version %j", (railpackVersion) => { + const result = apiSaveBuildType.safeParse({ + ...validRailpack, + railpackVersion, + }); + expect(result.success).toBe(true); + }); + + it.each([ + ["0.15.4; touch /tmp/pwned"], + ["0.15.4$(touch /tmp/x)"], + ["0.15.4`touch /tmp/x`"], + ["0.15.4 && touch /tmp/x"], + ["0.15.4 | touch /tmp/x"], + ["0.15.4\ntouch /tmp/x"], + ["3"], + ["0.15"], + ["v0.15.4"], + ["0.15.4;"], + [" 0.15.4"], + ])("rejects invalid railpack version %j", (railpackVersion) => { + const result = apiSaveBuildType.safeParse({ + ...validRailpack, + railpackVersion, + }); + expect(result.success).toBe(false); + }); + + it("accepts null railpackVersion (non-railpack save flow)", () => { + const result = apiSaveBuildType.safeParse({ + ...validHeroku, + railpackVersion: null, + }); + expect(result.success).toBe(true); + }); +}); + +describe("apiSaveBuildType herokuVersion validation", () => { + it.each(["24", "22", "20", ""])( + "accepts valid heroku stack %j (empty = use default)", + (herokuVersion) => { + const result = apiSaveBuildType.safeParse({ + ...validHeroku, + herokuVersion, + }); + expect(result.success).toBe(true); + }, + ); + + it.each([ + "24; touch /tmp/x", + "$(touch /tmp/x)", + "`touch /tmp/x`", + "24 && touch /tmp/x", + "24.0", + "v24", + ])("rejects invalid heroku version %j", (herokuVersion) => { + const result = apiSaveBuildType.safeParse({ + ...validHeroku, + herokuVersion, + }); + expect(result.success).toBe(false); + }); + + it("accepts null herokuVersion (non-heroku save flow)", () => { + const result = apiSaveBuildType.safeParse({ + ...validRailpack, + herokuVersion: null, + }); + expect(result.success).toBe(true); + }); +}); + +describe("apiUpdateApplication version field validation (partial)", () => { + it("accepts an omitted (undefined) railpackVersion", () => { + const result = apiUpdateApplication.safeParse({ applicationId: "app-1" }); + expect(result.success).toBe(true); + }); + + it("accepts null railpack/heroku version", () => { + const result = apiUpdateApplication.safeParse({ + applicationId: "app-1", + railpackVersion: null, + herokuVersion: null, + }); + expect(result.success).toBe(true); + }); + + it("rejects an injected railpackVersion", () => { + const result = apiUpdateApplication.safeParse({ + applicationId: "app-1", + railpackVersion: "0.15.4; touch /tmp/x", + }); + expect(result.success).toBe(false); + }); + + it("rejects an injected herokuVersion", () => { + const result = apiUpdateApplication.safeParse({ + applicationId: "app-1", + herokuVersion: "24; touch /tmp/x", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/apps/dokploy/__test__/deploy/railpack.injection.test.ts b/apps/dokploy/__test__/deploy/railpack.injection.test.ts new file mode 100644 index 000000000..30aaed3bf --- /dev/null +++ b/apps/dokploy/__test__/deploy/railpack.injection.test.ts @@ -0,0 +1,235 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import { getHerokuCommand } from "@dokploy/server/utils/builders/heroku"; +import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack"; +import { parse, quote } from "shell-quote"; +import { describe, expect, it } from "vitest"; + +// Regression tests for the shell-injection sinks in the Railpack and Heroku +// builders. `application.railpackVersion` / `application.herokuVersion` are +// user-controlled strings that get spliced into a bash script executed by +// `/bin/sh -c` (or over SSH). They must be quoted at the sink so a value like +// `0.15.4; touch /tmp/x` becomes a single-quoted, inert token instead of two +// runnable statements. + +const createApplication = ( + overrides: Partial = {}, +): ApplicationNested => + ({ + appName: "test-app", + buildType: "railpack", + sourceType: "git", + buildPath: "/", + railpackVersion: "0.15.4", + herokuVersion: "24", + env: "TEST_VAR=one", + cleanCache: false, + environment: { + project: { + env: "", + }, + env: "", + }, + ...overrides, + }) as unknown as ApplicationNested; + +// Payloads that would execute `touch ` if spliced unquoted into a bash +// line. Kept free of the words docker/curl/railpack/sudo/pack/bash so the +// neutralizer below never accidentally rewrites the injected command. +const buildPayloads = (mark: string): string[] => [ + `0.15.4; touch ${mark}`, + `0.15.4$(touch ${mark})`, + `0.15.4\`touch ${mark}\``, + `0.15.4 && touch ${mark}`, + `0.15.4 | touch ${mark}`, +]; + +// Replace the external binaries the script invokes with no-op stand-ins so the +// generated script can be executed by a real /bin/sh without reaching out to +// docker/railpack/curl/pack. Only line-leading command tokens are rewritten, so +// the same-named substrings inside paths (e.g. railpack-plan.json) are preserved. +const neutralizeExternals = (script: string): string => + script + .replace(/(^|\n)(\s*)docker(\s)/g, "$1$2:$3") + .replace(/(^|\n)(\s*)railpack(\s)/g, "$1$2:$3") + .replace(/(^|\n)(\s*)pack(\s)/g, "$1$2:$3") + .replace(/\bcurl\b/g, "true") + .replace(/\bsudo\b/g, "true"); + +// Run the (neutralized) full builder script through a real POSIX shell and +// report whether the injected marker file was created. Mirrors how the build +// command is executed in production: `execAsync` => `child_process.exec` => +// `/bin/sh -c "