mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
fix(builders): prevent shell injection from railpack/heroku custom versions
This commit is contained in:
parent
1572008cdf
commit
cc01a1df3c
142
apps/dokploy/__test__/deploy/railpack-version-schema.test.ts
Normal file
142
apps/dokploy/__test__/deploy/railpack-version-schema.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
235
apps/dokploy/__test__/deploy/railpack.injection.test.ts
Normal file
235
apps/dokploy/__test__/deploy/railpack.injection.test.ts
Normal file
@ -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> = {},
|
||||
): 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 <MARK>` 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 "<script>"`, wrapped in `set -e;`.
|
||||
const runScriptAndCheckInjection = (
|
||||
script: string,
|
||||
mark: string,
|
||||
): { fired: boolean; stdout: string; stderr: string } => {
|
||||
if (existsSync(mark)) rmSync(mark);
|
||||
const tmp = mkdtempSync(join(tmpdir(), "dokploy-inj-"));
|
||||
const scriptPath = join(tmp, "build.sh");
|
||||
try {
|
||||
writeFileSync(scriptPath, `set -e;\n${script}`);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
try {
|
||||
stdout = execFileSync("/bin/sh", [scriptPath], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
stderr = (err as { stderr?: string }).stderr ?? "";
|
||||
stdout = (err as { stdout?: string }).stdout ?? "";
|
||||
}
|
||||
const fired = existsSync(mark);
|
||||
if (existsSync(mark)) rmSync(mark);
|
||||
return { fired, stdout, stderr };
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
describe("railpack version command injection", () => {
|
||||
it("quotes railpackVersion in the `export RAILPACK_VERSION` assignment", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({ railpackVersion: "0.15.4; touch /tmp/pwned" }),
|
||||
);
|
||||
expect(command).toContain(
|
||||
"export RAILPACK_VERSION='0.15.4; touch /tmp/pwned'",
|
||||
);
|
||||
expect(command).not.toMatch(
|
||||
/export RAILPACK_VERSION=0\.15\.4;\s*touch \/tmp\/pwned/,
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes railpackVersion in the BUILDKIT_SYNTAX build-arg", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({ railpackVersion: "0.15.4; touch /tmp/pwned" }),
|
||||
);
|
||||
expect(command).toContain(
|
||||
"BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v'0.15.4; touch /tmp/pwned'",
|
||||
);
|
||||
expect(command).not.toMatch(
|
||||
/BUILDKIT_SYNTAX=ghcr\.io\/railwayapp\/railpack-frontend:v0\.15\.4;\s*touch/,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a legitimate semver railpackVersion unquoted and intact", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({ railpackVersion: "0.15.4" }),
|
||||
);
|
||||
expect(command).toContain("export RAILPACK_VERSION=0.15.4");
|
||||
expect(command).toContain(
|
||||
"BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v0.15.4",
|
||||
);
|
||||
expect(parse(quote(["0.15.4"]))).toEqual(["0.15.4"]);
|
||||
});
|
||||
|
||||
it("round-trips an injected version through shell-quote back to one token", () => {
|
||||
const injected = "0.15.4; touch /tmp/pwned";
|
||||
expect(parse(quote([injected]))).toEqual([injected]);
|
||||
});
|
||||
|
||||
it("handles a null railpackVersion as an empty quoted value", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({ railpackVersion: null as unknown as string }),
|
||||
);
|
||||
expect(command).toContain("export RAILPACK_VERSION=''");
|
||||
expect(command).toContain(
|
||||
"BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v'",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(buildPayloads("/tmp/dokploy_railpack_pwned"))(
|
||||
"does not execute the injected command for railpack payload %j",
|
||||
(payload) => {
|
||||
const mark = "/tmp/dokploy_railpack_pwned";
|
||||
const command = getRailpackCommand(
|
||||
createApplication({ railpackVersion: payload }),
|
||||
);
|
||||
// Sanity: the payload (unquoted form) must NOT appear as a bare runnable
|
||||
// statement in the generated script — it must live inside single quotes.
|
||||
const unquotedForm = payload.replace(/`/g, "\\`");
|
||||
const withoutQuoted = command.replace(/'[^']*'/g, "");
|
||||
expect(withoutQuoted).not.toContain(`touch ${mark}`);
|
||||
expect(withoutQuoted).not.toContain(unquotedForm);
|
||||
|
||||
const { fired } = runScriptAndCheckInjection(
|
||||
neutralizeExternals(command),
|
||||
mark,
|
||||
);
|
||||
expect(fired).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not fire when railpackVersion contains a path-separator-free command substitution", () => {
|
||||
const mark = "/tmp/dokploy_railpack_pwned2";
|
||||
const command = getRailpackCommand(
|
||||
createApplication({ railpackVersion: `0.15.4$(touch ${mark})` }),
|
||||
);
|
||||
const { fired } = runScriptAndCheckInjection(
|
||||
neutralizeExternals(command),
|
||||
mark,
|
||||
);
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("heroku version command injection", () => {
|
||||
it("quotes herokuVersion in the `--builder` argument", () => {
|
||||
const command = getHerokuCommand(
|
||||
createApplication({
|
||||
buildType: "heroku_buildpacks",
|
||||
herokuVersion: "24; touch /tmp/pwned",
|
||||
}),
|
||||
);
|
||||
expect(command).toContain(
|
||||
"--builder heroku/builder:'24; touch /tmp/pwned'",
|
||||
);
|
||||
expect(command).not.toMatch(
|
||||
/--builder heroku\/builder:24;\s*touch \/tmp\/pwned/,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a legitimate integer herokuVersion unquoted", () => {
|
||||
const command = getHerokuCommand(
|
||||
createApplication({
|
||||
buildType: "heroku_buildpacks",
|
||||
herokuVersion: "24",
|
||||
}),
|
||||
);
|
||||
expect(command).toContain("--builder heroku/builder:24");
|
||||
expect(parse(quote(["24"]))).toEqual(["24"]);
|
||||
});
|
||||
|
||||
it("falls back to the default `24` for an empty herokuVersion", () => {
|
||||
const command = getHerokuCommand(
|
||||
createApplication({ buildType: "heroku_buildpacks", herokuVersion: "" }),
|
||||
);
|
||||
expect(command).toContain("--builder heroku/builder:24");
|
||||
});
|
||||
|
||||
it.each(buildPayloads("/tmp/dokploy_heroku_pwned"))(
|
||||
"does not execute the injected command for heroku payload %j",
|
||||
(payload) => {
|
||||
const mark = "/tmp/dokploy_heroku_pwned";
|
||||
const command = getHerokuCommand(
|
||||
createApplication({
|
||||
buildType: "heroku_buildpacks",
|
||||
herokuVersion: payload,
|
||||
}),
|
||||
);
|
||||
const withoutQuoted = command.replace(/'[^']*'/g, "");
|
||||
expect(withoutQuoted).not.toContain(`touch ${mark}`);
|
||||
|
||||
const { fired } = runScriptAndCheckInjection(
|
||||
neutralizeExternals(command),
|
||||
mark,
|
||||
);
|
||||
expect(fired).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -352,8 +352,19 @@ const createSchema = createInsertSchema(applications, {
|
||||
"static",
|
||||
"railpack",
|
||||
]),
|
||||
railpackVersion: z.string().optional(),
|
||||
herokuVersion: z.string().optional(),
|
||||
railpackVersion: z
|
||||
.string()
|
||||
.regex(
|
||||
/^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/,
|
||||
"Invalid railpackVersion: must be a semver version (e.g. 0.15.4)",
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
herokuVersion: z
|
||||
.string()
|
||||
.regex(/^\d*$/, "Invalid herokuVersion: must be an integer (e.g. 24)")
|
||||
.nullable()
|
||||
.optional(),
|
||||
publishDirectory: z.string().optional(),
|
||||
isStaticSpa: z.boolean().optional(),
|
||||
createEnvFile: z.boolean().optional(),
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { quote } from "shell-quote";
|
||||
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
|
||||
import { getBuildAppDirectory } from "../filesystem/directory";
|
||||
import type { ApplicationNested } from ".";
|
||||
@ -18,7 +19,7 @@ export const getHerokuCommand = (application: ApplicationNested) => {
|
||||
"--path",
|
||||
buildAppDirectory,
|
||||
"--builder",
|
||||
`heroku/builder:${application.herokuVersion || "24"}`,
|
||||
`heroku/builder:${quote([application.herokuVersion || "24"])}`,
|
||||
];
|
||||
|
||||
if (cleanCache) {
|
||||
|
||||
@ -57,7 +57,7 @@ export const getRailpackCommand = (application: ApplicationNested) => {
|
||||
`secrets-hash=${secretsHash}`,
|
||||
...(cacheKey ? ["--build-arg", `cache-key=${cacheKey}`] : []),
|
||||
"--build-arg",
|
||||
`BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${application.railpackVersion}`,
|
||||
`BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${quote([application.railpackVersion ?? ""])}`,
|
||||
"-f",
|
||||
`${buildAppDirectory}/railpack-plan.json`,
|
||||
"--output",
|
||||
@ -86,7 +86,7 @@ export const getRailpackCommand = (application: ApplicationNested) => {
|
||||
|
||||
# Ensure we have a builder with containerd (isolated per build)
|
||||
|
||||
export RAILPACK_VERSION=${application.railpackVersion}
|
||||
export RAILPACK_VERSION=${quote([application.railpackVersion ?? ""])}
|
||||
# use sudo for non-root so the install can write to /usr/local/bin
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
SUDO_CMD=""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user