Merge pull request #5752 from Dokploy/fix/security-issues

fix: security issues
This commit is contained in:
Narciso E. Núñez Arias
2026-10-22 18:53:53 -04:00
committed by GitHub
32 changed files with 1463 additions and 67 deletions
@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createBackup: vi.fn(),
findBackupById: vi.fn(),
updateBackupById: vi.fn(),
findPostgresById: vi.fn(),
restorePostgresBackup: vi.fn(),
restoreWebServerBackup: vi.fn(),
findDestinationRow: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: { query: { destinations: { findFirst: mocks.findDestinationRow } } },
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkPermission: vi.fn(async () => undefined),
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
createBackup: mocks.createBackup,
findBackupById: mocks.findBackupById,
updateBackupById: mocks.updateBackupById,
findPostgresById: mocks.findPostgresById,
scheduleBackup: vi.fn(),
removeScheduleBackup: vi.fn(),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@dokploy/server/utils/restore", () => ({
restorePostgresBackup: mocks.restorePostgresBackup,
restoreWebServerBackup: mocks.restoreWebServerBackup,
}));
vi.mock("@dokploy/server/utils/backups/compose", () => ({
runComposeBackup: vi.fn(),
}));
vi.mock("@dokploy/server/utils/backups/utils", () => ({
getS3Credentials: vi.fn(),
normalizeS3Path: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/api/utils/plan-limits", () => ({
assertDatabaseBackupLimit: vi.fn(),
}));
vi.mock("@/server/utils/backup", () => ({
removeJob: vi.fn(),
schedule: vi.fn(),
updateJob: vi.fn(),
}));
const { backupRouter } = await import("@/server/api/routers/backup");
const caller = backupRouter.createCaller({
session: { activeOrganizationId: "org-a" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof backupRouter.createCaller>[0]);
const ownDestination = { destinationId: "dest-a", organizationId: "org-a" };
const foreignDestination = { destinationId: "dest-b", organizationId: "org-b" };
const createInput = {
schedule: "0 0 * * *",
prefix: "/",
database: "app",
databaseType: "postgres" as const,
postgresId: "pg-1",
enabled: false,
};
const updateInput = {
backupId: "backup-1",
schedule: "0 0 * * *",
enabled: false,
prefix: "/",
database: "app",
keepLatestCount: 0,
serviceName: null,
metadata: {},
databaseType: "postgres" as const,
};
const restoreInput = {
databaseId: "pg-1",
databaseType: "postgres" as const,
backupType: "database" as const,
databaseName: "app",
backupFile: "app/2024.sql.gz",
};
const drain = async (stream: unknown) => {
for await (const _ of stream as AsyncIterable<string>) {
}
};
describe("backup endpoints scope destinations to the active organization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.createBackup.mockResolvedValue({ backupId: "backup-1" });
mocks.findBackupById.mockResolvedValue({
backupId: "backup-1",
postgresId: "pg-1",
destinationId: "dest-a",
enabled: false,
databaseType: "postgres",
backupType: "database",
});
mocks.findPostgresById.mockResolvedValue({ postgresId: "pg-1" });
});
it("rejects creating a backup on a destination from another organization", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
await expect(
caller.create({ ...createInput, destinationId: "dest-b" }),
).rejects.toMatchObject({
message: "You don't have access to this destination.",
});
expect(mocks.createBackup).not.toHaveBeenCalled();
});
it("creates a backup on a destination of the active organization", async () => {
mocks.findDestinationRow.mockResolvedValue(ownDestination);
await caller.create({ ...createInput, destinationId: "dest-a" });
expect(mocks.createBackup).toHaveBeenCalledTimes(1);
});
it("rejects moving a backup to a destination from another organization", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
await expect(
caller.update({ ...updateInput, destinationId: "dest-b" }),
).rejects.toMatchObject({
message: "You don't have access to this destination.",
});
expect(mocks.updateBackupById).not.toHaveBeenCalled();
});
it("rejects restoring from a destination of another organization", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
const stream = await caller.restoreBackupWithLogs({
...restoreInput,
destinationId: "dest-b",
});
await expect(drain(stream)).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
expect(mocks.restorePostgresBackup).not.toHaveBeenCalled();
});
it("restores from a destination of the active organization", async () => {
mocks.findDestinationRow.mockResolvedValue(ownDestination);
const stream = await caller.restoreBackupWithLogs({
...restoreInput,
destinationId: "dest-a",
});
await drain(stream);
expect(mocks.restorePostgresBackup).toHaveBeenCalledTimes(1);
});
it("leaves service-less web-server schedules unscoped (maintainer decision)", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
await caller.create({
...createInput,
postgresId: undefined,
databaseType: "web-server",
destinationId: "dest-b",
});
expect(mocks.createBackup).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findDestinationRow: vi.fn(),
createDeploymentBackup: vi.fn(),
createDeploymentVolumeBackup: vi.fn(),
updateDeploymentStatus: vi.fn(),
findVolumeBackupById: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: { query: { destinations: { findFirst: mocks.findDestinationRow } } },
}));
vi.mock("@dokploy/server/constants", () => ({
paths: () => ({ VOLUME_BACKUPS_PATH: "/tmp/volume-backups" }),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeploymentBackup: mocks.createDeploymentBackup,
createDeploymentVolumeBackup: mocks.createDeploymentVolumeBackup,
updateDeploymentStatus: mocks.updateDeploymentStatus,
}));
vi.mock("@dokploy/server/services/environment", () => ({
findEnvironmentById: vi.fn(async () => ({ projectId: "project-1" })),
}));
vi.mock("@dokploy/server/services/project", () => ({
findProjectById: vi.fn(async () => ({
name: "project",
organizationId: "org-a",
})),
}));
vi.mock("@dokploy/server/services/volume-backups", () => ({
findVolumeBackupById: mocks.findVolumeBackupById,
}));
vi.mock("@dokploy/server/utils/backups/utils", () => ({
getBackupCommand: vi.fn(() => "echo backup"),
getBackupTimestamp: vi.fn(() => "2024"),
getS3Credentials: vi.fn(() => []),
normalizeS3Path: vi.fn(() => ""),
}));
vi.mock("@dokploy/server/utils/volume-backups/backup", () => ({
backupVolume: vi.fn(async () => "echo backup"),
getVolumeServiceAppName: vi.fn(() => "app"),
}));
vi.mock("@dokploy/server/utils/notifications/database-backup", () => ({
sendDatabaseBackupNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/volume-backup", () => ({
sendVolumeBackupNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
const { runPostgresBackup } = await import(
"@dokploy/server/utils/backups/postgres"
);
const { runVolumeBackup } = await import(
"@dokploy/server/utils/volume-backups/utils"
);
const ownDestination = {
destinationId: "dest-a",
organizationId: "org-a",
bucket: "bucket",
};
const foreignDestination = {
destinationId: "dest-b",
organizationId: "org-b",
bucket: "bucket",
};
const postgres = {
name: "db",
appName: "db-app",
environmentId: "env-1",
serverId: null,
databaseUser: "postgres",
};
const backup = {
backupId: "backup-1",
prefix: "/",
database: "app",
databaseType: "postgres",
backupType: "database",
};
const volumeBackup = {
volumeBackupId: "vb-1",
name: "data",
appName: "vb-app",
volumeName: "app_data",
serviceType: "application",
destinationId: "dest-b",
keepLatestCount: 0,
application: {
appName: "app",
serverId: null,
environment: { project: { name: "project", organizationId: "org-a" } },
},
};
beforeEach(() => {
vi.clearAllMocks();
mocks.createDeploymentBackup.mockResolvedValue({
deploymentId: "dep-1",
logPath: "/tmp/dep-1.log",
});
mocks.createDeploymentVolumeBackup.mockResolvedValue({
deploymentId: "dep-1",
logPath: "/tmp/dep-1.log",
});
mocks.execAsync.mockResolvedValue({ stdout: "", stderr: "" });
mocks.findVolumeBackupById.mockResolvedValue(volumeBackup);
});
describe("runPostgresBackup checks the destination organization before side effects", () => {
it("aborts before creating a deployment when the destination belongs to another organization", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
await expect(
runPostgresBackup(postgres as never, backup as never),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.createDeploymentBackup).not.toHaveBeenCalled();
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("runs when the destination belongs to the project organization", async () => {
mocks.findDestinationRow.mockResolvedValue(ownDestination);
await runPostgresBackup(postgres as never, backup as never);
expect(mocks.execAsync).toHaveBeenCalledTimes(1);
expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith("dep-1", "done");
});
});
describe("runVolumeBackup checks the destination organization before side effects", () => {
it("aborts before creating a deployment when the destination belongs to another organization", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
await expect(runVolumeBackup("vb-1")).rejects.toMatchObject({
code: "UNAUTHORIZED",
});
expect(mocks.createDeploymentVolumeBackup).not.toHaveBeenCalled();
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("runs when the destination belongs to the project organization", async () => {
mocks.findDestinationRow.mockResolvedValue(ownDestination);
await runVolumeBackup("vb-1");
expect(mocks.execAsync).toHaveBeenCalledTimes(1);
expect(mocks.updateDeploymentStatus).toHaveBeenCalledWith("dep-1", "done");
});
});
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createVolumeBackup: vi.fn(),
findVolumeBackupById: vi.fn(),
updateVolumeBackup: vi.fn(),
findDestinationRow: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: { query: { destinations: { findFirst: mocks.findDestinationRow } } },
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkPermission: vi.fn(async () => undefined),
checkServicePermissionAndAccess: vi.fn(async () => undefined),
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
createVolumeBackup: mocks.createVolumeBackup,
findVolumeBackupById: mocks.findVolumeBackupById,
updateVolumeBackup: mocks.updateVolumeBackup,
scheduleVolumeBackup: vi.fn(),
removeVolumeBackupJob: vi.fn(),
removeVolumeBackup: vi.fn(),
restoreVolume: vi.fn(),
runVolumeBackup: vi.fn(),
}));
vi.mock("@dokploy/server/lib/auth", () => ({
validateRequest: vi.fn(),
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsyncRemote: vi.fn(),
execAsyncStream: vi.fn(),
}));
vi.mock("@/server/api/utils/audit", () => ({
audit: vi.fn(async () => undefined),
}));
vi.mock("@/server/api/utils/plan-limits", () => ({
assertVolumeBackupLimit: vi.fn(),
}));
vi.mock("@/server/utils/backup", () => ({
removeJob: vi.fn(),
schedule: vi.fn(),
updateJob: vi.fn(),
}));
const { volumeBackupsRouter } = await import(
"@/server/api/routers/volume-backups"
);
const caller = volumeBackupsRouter.createCaller({
session: { activeOrganizationId: "org-a" },
user: { id: "user-1", email: "user@example.com", role: "owner" },
} as Parameters<typeof volumeBackupsRouter.createCaller>[0]);
const ownDestination = { destinationId: "dest-a", organizationId: "org-a" };
const foreignDestination = { destinationId: "dest-b", organizationId: "org-b" };
const createInput = {
name: "data",
volumeName: "app_data",
prefix: "/",
cronExpression: "0 0 * * *",
serviceType: "application" as const,
applicationId: "app-1",
enabled: false,
};
describe("volume backup endpoints scope destinations to the active organization", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.createVolumeBackup.mockResolvedValue({
volumeBackupId: "vb-1",
enabled: false,
});
mocks.findVolumeBackupById.mockResolvedValue({
volumeBackupId: "vb-1",
applicationId: "app-1",
destinationId: "dest-a",
});
mocks.updateVolumeBackup.mockResolvedValue({
volumeBackupId: "vb-1",
enabled: false,
});
});
it("rejects creating a volume backup on a destination from another organization", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
await expect(
caller.create({ ...createInput, destinationId: "dest-b" }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.createVolumeBackup).not.toHaveBeenCalled();
});
it("creates a volume backup on a destination of the active organization", async () => {
mocks.findDestinationRow.mockResolvedValue(ownDestination);
await caller.create({ ...createInput, destinationId: "dest-a" });
expect(mocks.createVolumeBackup).toHaveBeenCalledTimes(1);
});
it("rejects moving a volume backup to a destination from another organization", async () => {
mocks.findDestinationRow.mockResolvedValue(foreignDestination);
await expect(
caller.update({
...createInput,
volumeBackupId: "vb-1",
destinationId: "dest-b",
}),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.updateVolumeBackup).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,169 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const CERTIFICATES_PATH = "/etc/dokploy/traefik/dynamic/certificates";
const mocks = vi.hoisted(() => ({
findCertificateRow: vi.fn(),
insertReturning: vi.fn(),
deleteWhere: vi.fn(),
removeDirectoryIfExistsContent: vi.fn(),
execAsyncRemote: vi.fn(),
existsSync: vi.fn(() => false),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
}));
vi.mock("node:fs", () => ({
default: {
existsSync: mocks.existsSync,
mkdirSync: mocks.mkdirSync,
writeFileSync: mocks.writeFileSync,
},
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: { certificates: { findFirst: mocks.findCertificateRow } },
insert: () => ({
values: () => ({ returning: mocks.insertReturning }),
}),
delete: () => ({
where: () => ({ returning: mocks.deleteWhere }),
}),
},
}));
vi.mock("@dokploy/server/constants", () => ({
paths: () => ({ CERTIFICATES_PATH }),
}));
vi.mock("@dokploy/server/utils/filesystem/directory", () => ({
removeDirectoryIfExistsContent: mocks.removeDirectoryIfExistsContent,
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
encodeBase64: (value: string) => Buffer.from(value).toString("base64"),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsyncRemote: mocks.execAsyncRemote,
}));
const { apiCreateCertificate } = await import("@dokploy/server/db/schema");
const { createCertificate, removeCertificateById } = await import(
"@dokploy/server/services/certificate"
);
const certificate = {
certificateId: "cert-1",
name: "example",
certificateData: "CERT",
privateKey: "KEY",
organizationId: "org-a",
serverId: null,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.existsSync.mockReturnValue(false);
mocks.deleteWhere.mockResolvedValue([certificate]);
});
describe("certificate create input", () => {
it("ignores a client-supplied certificatePath", () => {
const parsed = apiCreateCertificate.parse({
name: "example",
certificateData: "CERT",
privateKey: "KEY",
organizationId: "org-a",
certificatePath: "../../../../tmp/escape",
});
expect(parsed).not.toHaveProperty("certificatePath");
});
});
describe("certificate files stay inside CERTIFICATES_PATH", () => {
it.each(["../../../../tmp/escape", "/tmp/escape", "", "."])(
"refuses to delete the directory resolved from %j",
async (certificatePath) => {
mocks.findCertificateRow.mockResolvedValue({
...certificate,
certificatePath,
});
await expect(removeCertificateById("cert-1")).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.removeDirectoryIfExistsContent).not.toHaveBeenCalled();
expect(mocks.deleteWhere).not.toHaveBeenCalled();
},
);
it("refuses to delete an escaping directory on a remote server", async () => {
mocks.findCertificateRow.mockResolvedValue({
...certificate,
serverId: "server-1",
certificatePath: "../../../../tmp/escape",
});
await expect(removeCertificateById("cert-1")).rejects.toMatchObject({
code: "BAD_REQUEST",
});
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("deletes a directory inside CERTIFICATES_PATH", async () => {
mocks.findCertificateRow.mockResolvedValue({
...certificate,
certificatePath: "certificate-abc123",
});
await removeCertificateById("cert-1");
expect(mocks.removeDirectoryIfExistsContent).toHaveBeenCalledWith(
`${CERTIFICATES_PATH}/certificate-abc123`,
);
});
it("refuses to write certificate files outside CERTIFICATES_PATH", async () => {
mocks.insertReturning.mockResolvedValue([
{ ...certificate, certificatePath: "../../../../tmp/escape" },
]);
await expect(
createCertificate(
{
name: "example",
certificateData: "CERT",
privateKey: "KEY",
organizationId: "org-a",
},
"org-a",
),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mocks.mkdirSync).not.toHaveBeenCalled();
expect(mocks.writeFileSync).not.toHaveBeenCalled();
});
it("writes certificate files inside CERTIFICATES_PATH", async () => {
mocks.insertReturning.mockResolvedValue([
{ ...certificate, certificatePath: "certificate-abc123" },
]);
await createCertificate(
{
name: "example",
certificateData: "CERT",
privateKey: "KEY",
organizationId: "org-a",
},
"org-a",
);
expect(mocks.mkdirSync).toHaveBeenCalledWith(
`${CERTIFICATES_PATH}/certificate-abc123`,
{ recursive: true },
);
});
});
@@ -0,0 +1,39 @@
import { apiUpdateCompose } from "@dokploy/server/db/schema";
import { describe, expect, it } from "vitest";
const branchFields = [
"branch",
"gitlabBranch",
"bitbucketBranch",
"giteaBranch",
"customGitBranch",
] as const;
describe("compose update validates provider branch names", () => {
it.each(branchFields)("rejects shell metacharacters in %s", (field) => {
const result = apiUpdateCompose.safeParse({
composeId: "compose-1",
[field]: "main; rm -rf /",
});
expect(result.success).toBe(false);
});
it.each(branchFields)("accepts a valid %s", (field) => {
const result = apiUpdateCompose.safeParse({
composeId: "compose-1",
[field]: "feature/login-v2.0",
});
expect(result.success).toBe(true);
});
it.each(branchFields)("still allows clearing %s with null", (field) => {
const result = apiUpdateCompose.safeParse({
composeId: "compose-1",
[field]: null,
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/services/github", () => ({
findGithubById: vi.fn(async () => ({
githubId: "gh-1",
githubUrl: "https://github.com",
githubAppId: 1,
githubPrivateKey: "key",
githubInstallationId: "42",
})),
}));
vi.mock("@octokit/auth-app", () => ({
createAppAuth: vi.fn(),
}));
vi.mock("octokit", () => ({
Octokit: class {
auth = async () => ({ token: "gh-token" });
},
}));
vi.mock("@dokploy/server/services/gitlab", () => ({
findGitlabById: vi.fn(async () => ({
gitlabId: "gl-1",
gitlabUrl: "https://gitlab.example.com",
gitlabInternalUrl: null,
accessToken: "gl-token",
expiresAt: Math.floor(Date.now() / 1000) + 3600,
})),
updateGitlab: vi.fn(),
}));
vi.mock("@dokploy/server/services/gitea", () => ({
findGiteaById: vi.fn(async () => ({
giteaId: "gt-1",
giteaUrl: "https://gitea.example.com:3000",
giteaInternalUrl: null,
accessToken: "gt-token",
})),
updateGitea: vi.fn(),
}));
const { cloneGithubRepository } = await import(
"@dokploy/server/utils/providers/github"
);
const { cloneGitlabRepository } = await import(
"@dokploy/server/utils/providers/gitlab"
);
const { cloneGiteaRepository } = await import(
"@dokploy/server/utils/providers/gitea"
);
const basic = (token: string) =>
Buffer.from(`oauth2:${token}`).toString("base64");
const base = { appName: "my-app", enableSubmodules: true, serverId: null };
describe("clone commands keep the token out of the URL (#5618)", () => {
it.each([
{
provider: "github",
token: "gh-token",
origin: "https://github.com",
url: "https://github.com/acme/web.git",
build: () =>
cloneGithubRepository({
...base,
owner: "acme",
repository: "web",
branch: "main",
githubId: "gh-1",
}),
},
{
provider: "gitlab",
token: "gl-token",
origin: "https://gitlab.example.com",
url: "https://gitlab.example.com/acme/web.git",
build: () =>
cloneGitlabRepository({
...base,
gitlabBranch: "main",
gitlabId: "gl-1",
gitlabPathNamespace: "acme/web",
gitlabOwner: "acme",
gitlabRepository: "web",
} as Parameters<typeof cloneGitlabRepository>[0]),
},
{
provider: "gitea",
token: "gt-token",
origin: "https://gitea.example.com:3000",
url: "https://gitea.example.com:3000/acme/web.git",
build: () =>
cloneGiteaRepository({
...base,
giteaBranch: "main",
giteaId: "gt-1",
giteaOwner: "acme",
giteaRepository: "web",
}),
},
])(
"$provider sends the token as a host-scoped header",
async ({ token, origin, url, build }) => {
const command = (await build()).replace(/\\/g, "");
expect(command).not.toContain(`${token}@`);
expect(command).toContain(
`http.${origin}/.extraHeader=Authorization: Basic ${basic(token)}`,
);
expect(command).toContain(`--recurse-submodules ${url} `);
},
);
});
@@ -53,9 +53,7 @@ describe("cloneGithubRepository host", () => {
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
expect(command).toContain("https://github.com/acme/web.git");
expect(command).not.toContain("ghe.com");
});
@@ -64,9 +62,7 @@ describe("cloneGithubRepository host", () => {
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@acme.ghe.com/acme/web.git",
);
expect(command).toContain("https://acme.ghe.com/acme/web.git");
expect(command).not.toContain("github.com");
});
@@ -77,9 +73,7 @@ describe("cloneGithubRepository host", () => {
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.corp.acme.com/acme/web.git",
);
expect(command).toContain("https://github.corp.acme.com/acme/web.git");
});
it("keeps an explicit port in the clone host", async () => {
@@ -89,9 +83,7 @@ describe("cloneGithubRepository host", () => {
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.acme.com:8443/acme/web.git",
);
expect(command).toContain("https://github.acme.com:8443/acme/web.git");
});
it("falls back to github.com for a provider stored before this feature", async () => {
@@ -99,8 +91,6 @@ describe("cloneGithubRepository host", () => {
const command = await clone();
expect(command).toContain(
"https://oauth2:gh-token@github.com/acme/web.git",
);
expect(command).toContain("https://github.com/acme/web.git");
});
});
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockExecAsync = vi.hoisted(() => vi.fn());
const mockExecAsyncRemote = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mockExecAsync,
execAsyncRemote: mockExecAsyncRemote,
}));
import {
formatBuildLogTail,
getBuildErrorMessage,
} from "@dokploy/server/utils/notifications/build-error-message";
import { ExecError } from "@dokploy/server/utils/process/ExecError";
// Synthetic key material, not a real key.
const SCRIPT = `set -e; echo "-----BEGIN OPENSSH PRIVATE KEY-----
c3ludGhldGljLXRlc3Qta2V5
-----END OPENSSH PRIVATE KEY-----" > /tmp/id_rsa; nixpacks build /code`;
const LOG_PATH = "/etc/dokploy/logs/web-abc/web-abc-2026.log";
const buildFailure = () =>
new ExecError(`Command execution failed: Command failed: ${SCRIPT}`, {
command: SCRIPT,
stdout: "",
stderr: "",
exitCode: 1,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("formatBuildLogTail", () => {
it("keeps only the last lines of the log", () => {
const log = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`).join(
"\n",
);
const tail = formatBuildLogTail(log, 3);
expect(tail).toBe("line 48\nline 49\nline 50");
});
it("ignores trailing blank lines", () => {
expect(formatBuildLogTail("a\nb\n\n\n", 2)).toBe("a\nb");
});
});
describe("getBuildErrorMessage", () => {
it("uses the build log, never the build script, for a failed build", async () => {
mockExecAsync.mockResolvedValue({
stdout:
"src/index.ts:3:7 - error TS2322: Type 'number' is not assignable to type 'string'.\n❌ Nixpacks build failed\n",
stderr: "",
});
const message = await getBuildErrorMessage(buildFailure(), LOG_PATH);
expect(message).toContain("error TS2322");
expect(message).not.toContain("PRIVATE KEY");
expect(message).not.toContain("c3ludGhldGljLXRlc3Qta2V5");
expect(mockExecAsync).toHaveBeenCalledWith(`tail -n 30 ${LOG_PATH}`);
expect(mockExecAsyncRemote).not.toHaveBeenCalled();
});
it("reads the log on the build server for remote builds", async () => {
mockExecAsyncRemote.mockResolvedValue({
stdout: "build failed\n",
stderr: "",
});
const message = await getBuildErrorMessage(
buildFailure(),
LOG_PATH,
"server-1",
);
expect(message).toBe("build failed");
expect(mockExecAsyncRemote).toHaveBeenCalledWith(
"server-1",
`tail -n 30 ${LOG_PATH}`,
);
});
it("falls back to a generic message when the log cannot be read", async () => {
mockExecAsync.mockRejectedValue(new Error("No such file"));
const message = await getBuildErrorMessage(buildFailure(), LOG_PATH);
expect(message).toBe("Error building");
});
it("keeps the message of errors that are not build commands", async () => {
const message = await getBuildErrorMessage(
new Error("Repository not found"),
LOG_PATH,
);
expect(message).toBe("Repository not found");
expect(mockExecAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,188 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (
role: string,
accessedServices: string[] = [],
accessedProjects: string[] = [],
accessedEnvironments: string[] = [],
) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-a",
accessedProjects,
accessedServices,
accessedEnvironments,
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn: ReturnType<typeof mockMemberData> = mockMemberData("owner");
// Two organizations; every id encodes the org that owns it.
const serviceOrgs: Record<string, string> = {
"app-a": "org-a",
"app-b": "org-b",
"postgres-b": "org-b",
};
const projectOrgs: Record<string, string> = {
"project-a": "org-a",
"project-b": "org-b",
};
const environmentOrgs: Record<string, string> = {
"env-a": "org-a",
"env-b": "org-b",
};
// Pulls the bound id out of a drizzle eq() expression.
const idFromWhere = (where: unknown) => {
const chunks = (where as { queryChunks?: unknown[] })?.queryChunks ?? [];
const param = chunks.find(
(c) => typeof (c as { value?: unknown })?.value === "string",
) as { value: string } | undefined;
return param?.value;
};
vi.mock("@dokploy/server/db", () => {
const selectChain = {
from: () => selectChain,
innerJoin: () => selectChain,
where: (where: unknown) => {
const id = idFromWhere(where);
const organizationId = id ? serviceOrgs[id] : undefined;
return Promise.resolve(organizationId ? [{ organizationId }] : []);
},
};
return {
db: {
select: vi.fn(() => selectChain),
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
projects: {
findFirst: vi.fn(({ where }: { where: unknown }) => {
const id = idFromWhere(where);
const organizationId = id ? projectOrgs[id] : undefined;
return Promise.resolve(
organizationId ? { organizationId } : undefined,
);
}),
},
environments: {
findFirst: vi.fn(({ where }: { where: unknown }) => {
const id = idFromWhere(where);
const organizationId = id ? environmentOrgs[id] : undefined;
return Promise.resolve(
organizationId ? { project: { organizationId } } : undefined,
);
}),
},
},
},
};
});
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
}));
const {
checkEnvironmentCreationPermission,
checkServiceAccess,
checkServicePermissionAndAccess,
} = await import("@dokploy/server/services/permission");
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-a" },
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("organization scope of service helpers", () => {
it("admin of org A cannot pass checkServicePermissionAndAccess for a service of org B", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkServicePermissionAndAccess(ctx, "app-b", { envVars: ["write"] }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("owner of org A cannot pass checkServiceAccess for a database of org B", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkServiceAccess(ctx, "postgres-b", "read"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
await expect(
checkServiceAccess(ctx, "postgres-b", "delete"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("owner of org A cannot create a service in a project of org B", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkServiceAccess(ctx, "project-b", "create"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("owner of org A cannot create an environment in a project of org B", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkEnvironmentCreationPermission(ctx, "project-b"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
await expect(
checkEnvironmentCreationPermission(ctx, "project-a"),
).resolves.toBeUndefined();
});
it("member of org A with a stale access entry for an org B service is rejected", async () => {
memberToReturn = mockMemberData("member", ["app-b"]);
await expect(
checkServicePermissionAndAccess(ctx, "app-b", { deployment: ["read"] }),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});
it("unknown service id is NOT_FOUND", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkServicePermissionAndAccess(ctx, "missing", { deployment: ["read"] }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("owner and admin still pass for services and projects of their own org", async () => {
memberToReturn = mockMemberData("admin");
await expect(
checkServicePermissionAndAccess(ctx, "app-a", { envVars: ["write"] }),
).resolves.toBeUndefined();
await expect(
checkServiceAccess(ctx, "app-a", "read"),
).resolves.toBeUndefined();
await expect(
checkServiceAccess(ctx, "project-a", "create"),
).resolves.toBeUndefined();
});
it("member with access still passes for a service of their own org", async () => {
memberToReturn = mockMemberData("member", ["app-a"]);
await expect(
checkServicePermissionAndAccess(ctx, "app-a", { deployment: ["read"] }),
).resolves.toBeUndefined();
});
});
@@ -29,20 +29,32 @@ const mockMemberData = (
let memberToReturn: ReturnType<typeof mockMemberData> =
mockMemberData("member");
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
// Every service/project id used below belongs to the caller's org-1.
vi.mock("@dokploy/server/db", () => {
const selectChain = {
from: () => selectChain,
innerJoin: () => selectChain,
where: () => Promise.resolve([{ organizationId: "org-1" }]),
};
return {
db: {
select: vi.fn(() => selectChain),
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve([])),
},
projects: {
findFirst: vi.fn(() => Promise.resolve({ organizationId: "org-1" })),
},
},
},
},
}));
};
});
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(false)),
+18 -2
View File
@@ -28,7 +28,10 @@ import {
scheduleBackup,
updateBackupById,
} from "@dokploy/server";
import { findDestinationById } from "@dokploy/server/services/destination";
import {
findDestinationById,
findDestinationByIdForOrganization,
} from "@dokploy/server/services/destination";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { runComposeBackup } from "@dokploy/server/utils/backups/compose";
import {
@@ -95,6 +98,10 @@ export const backupRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["create"],
});
await findDestinationByIdForOrganization(
input.destinationId,
ctx.session.activeOrganizationId,
);
}
if (IS_CLOUD) {
@@ -206,6 +213,10 @@ export const backupRouter = createTRPCRouter({
await checkServicePermissionAndAccess(ctx, serviceId, {
backup: ["update"],
});
await findDestinationByIdForOrganization(
input.destinationId,
ctx.session.activeOrganizationId,
);
}
await updateBackupById(input.backupId, input);
@@ -579,7 +590,12 @@ export const backupRouter = createTRPCRouter({
backup: ["restore"],
});
}
const destination = await findDestinationById(input.destinationId);
const destination = input.databaseId
? await findDestinationByIdForOrganization(
input.destinationId,
ctx.session.activeOrganizationId,
)
: await findDestinationById(input.destinationId);
const queue: string[] = [];
let done = false;
const onLog = (log: string) => queue.push(log);
+46 -3
View File
@@ -5,8 +5,12 @@ import {
findAllDeploymentsByComposeId,
findAllDeploymentsByServerId,
findAllDeploymentsCentralized,
findBackupById,
findDeploymentById,
findPreviewDeploymentById,
findScheduleById,
findVolumeBackupById,
getAccessibleServerIds,
IS_CLOUD,
removeDeployment,
resolveServicePath,
@@ -14,6 +18,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
checkPermission,
checkServicePermissionAndAccess,
findMemberByUserId,
} from "@dokploy/server/services/permission";
@@ -145,10 +150,48 @@ export const deploymentRouter = createTRPCRouter({
});
}
}
} else if (input.type === "server") {
const accessibleServerIds = await getAccessibleServerIds(ctx.session);
if (!accessibleServerIds.has(input.id)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
await checkPermission(ctx, { deployment: ["read"] });
} else {
await checkServicePermissionAndAccess(ctx, input.id, {
deployment: ["read"],
});
let serviceId: string | null | undefined = input.id;
if (input.type === "backup") {
const backup = await findBackupById(input.id);
serviceId =
backup.postgresId ||
backup.mysqlId ||
backup.mariadbId ||
backup.mongoId ||
backup.libsqlId ||
backup.composeId;
} else if (input.type === "volumeBackup") {
const volumeBackup = await findVolumeBackupById(input.id);
serviceId =
volumeBackup.applicationId ||
volumeBackup.composeId ||
volumeBackup.postgresId ||
volumeBackup.mysqlId ||
volumeBackup.mariadbId ||
volumeBackup.mongoId ||
volumeBackup.redisId ||
volumeBackup.libsqlId;
} else if (input.type === "previewDeployment") {
const preview = await findPreviewDeploymentById(input.id);
serviceId = preview.applicationId;
}
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
deployment: ["read"],
});
} else {
await checkPermission(ctx, { deployment: ["read"] });
}
}
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments[`${input.type}Id`], input.id),
@@ -17,7 +17,10 @@ import {
VOLUME_NAME_REGEX,
volumeBackups,
} from "@dokploy/server/db/schema";
import { findDestinationById } from "@dokploy/server/services/destination";
import {
findDestinationById,
findDestinationByIdForOrganization,
} from "@dokploy/server/services/destination";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { findServerById } from "@dokploy/server/services/server";
import {
@@ -100,6 +103,10 @@ export const volumeBackupsRouter = createTRPCRouter({
volumeBackup: ["create"],
});
}
await findDestinationByIdForOrganization(
input.destinationId,
ctx.session.activeOrganizationId,
);
if (IS_CLOUD && serviceType && serviceId) {
const existingVolumeBackups = await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
@@ -201,6 +208,10 @@ export const volumeBackupsRouter = createTRPCRouter({
volumeBackup: ["update"],
});
}
await findDestinationByIdForOrganization(
input.destinationId,
ctx.session.activeOrganizationId,
);
const updatedVolumeBackup = await updateVolumeBackup(
input.volumeBackupId,
input,
+1 -1
View File
@@ -45,7 +45,7 @@ export const apiCreateCertificate = createInsertSchema(certificates, {
privateKey: z.string().min(1),
autoRenew: z.boolean().optional(),
serverId: z.string().optional(),
});
}).omit({ certificatePath: true });
export const apiFindCertificate = z.object({
certificateId: z.string().min(1),
+13
View File
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { relations } from "drizzle-orm";
import {
boolean,
@@ -171,8 +172,20 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
patches: many(patch),
}));
const branchField = z
.string()
.min(1)
.regex(VALID_BRANCH_REGEX, "Invalid branch name")
.nullable()
.optional();
const createSchema = createInsertSchema(compose, {
name: z.string().min(1),
branch: branchField,
gitlabBranch: branchField,
bitbucketBranch: branchField,
giteaBranch: branchField,
customGitBranch: branchField,
appName: z
.string()
.min(1)
+7 -2
View File
@@ -11,6 +11,7 @@ import {
mechanizeDockerContainer,
} from "@dokploy/server/utils/builders";
import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error";
import { getBuildErrorMessage } from "@dokploy/server/utils/notifications/build-error-message";
import { sendBuildSuccessNotifications } from "@dokploy/server/utils/notifications/build-success";
import {
ExecError,
@@ -245,6 +246,11 @@ export const deployApplication = async ({
environmentName: application.environment.name,
});
} catch (error) {
const errorMessage = await getBuildErrorMessage(
error,
deployment.logPath,
serverId,
);
let command = "";
// Only log details for non-ExecError errors
@@ -267,8 +273,7 @@ export const deployApplication = async ({
projectName: application.environment.project.name,
applicationName: application.name,
applicationType: "application",
// @ts-ignore
errorMessage: error?.message || "Error building",
errorMessage,
buildLink,
organizationId: application.environment.project.organizationId,
});
+17 -5
View File
@@ -53,15 +53,28 @@ export const createCertificate = async (
const cer = certificate[0];
createCertificateFiles(cer);
await createCertificateFiles(cer);
return cer;
};
// certificatePath feeds rm -rf and mkdir -p; a tampered row must not escape the certificates dir
const resolveCertificateDir = (certificate: Certificate) => {
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
const base = path.resolve(CERTIFICATES_PATH);
const certDir = path.resolve(base, certificate.certificatePath);
if (!certDir.startsWith(`${base}${path.sep}`)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid certificate path",
});
}
return certDir;
};
export const removeCertificateById = async (certificateId: string) => {
const certificate = await findCertificateById(certificateId);
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
const certDir = resolveCertificateDir(certificate);
if (certificate.serverId) {
await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
@@ -85,8 +98,7 @@ export const removeCertificateById = async (certificateId: string) => {
};
const createCertificateFiles = async (certificate: Certificate) => {
const { CERTIFICATES_PATH } = paths(!!certificate.serverId);
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
const certDir = resolveCertificateDir(certificate);
const crtPath = path.join(certDir, "chain.crt");
const keyPath = path.join(certDir, "privkey.key");
+7 -2
View File
@@ -16,6 +16,7 @@ import {
} from "@dokploy/server/utils/docker/domain";
import type { ComposeSpecification } from "@dokploy/server/utils/docker/types";
import { sendBuildErrorNotifications } from "@dokploy/server/utils/notifications/build-error";
import { getBuildErrorMessage } from "@dokploy/server/utils/notifications/build-error-message";
import { sendBuildSuccessNotifications } from "@dokploy/server/utils/notifications/build-success";
import {
ExecError,
@@ -322,6 +323,11 @@ export const deployCompose = async ({
environmentName: compose.environment.name,
});
} catch (error) {
const errorMessage = await getBuildErrorMessage(
error,
deployment.logPath,
compose.serverId,
);
let command = "";
// Only log details for non-ExecError errors
@@ -345,8 +351,7 @@ export const deployCompose = async ({
projectName: compose.environment.project.name,
applicationName: compose.name,
applicationType: "compose",
// @ts-ignore
errorMessage: error?.message || "Error building",
errorMessage,
buildLink,
organizationId: compose.environment.project.organizationId,
});
@@ -45,6 +45,20 @@ export const findDestinationById = async (destinationId: string) => {
return destination;
};
export const findDestinationByIdForOrganization = async (
destinationId: string,
organizationId: string,
) => {
const destination = await findDestinationById(destinationId);
if (destination.organizationId !== organizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this destination.",
});
}
return destination;
};
export const removeDestinationById = async (
destinationId: string,
organizationId: string,
+106 -1
View File
@@ -1,5 +1,18 @@
import { db } from "@dokploy/server/db";
import { member, organizationRole } from "@dokploy/server/db/schema";
import {
applications,
compose,
environments,
libsql,
mariadb,
member,
mongo,
mysql,
organizationRole,
postgres,
projects,
redis,
} from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
@@ -215,6 +228,88 @@ export const resolvePermissions = async (
return result;
};
const serviceTables = [
[applications, applications.applicationId],
[compose, compose.composeId],
[postgres, postgres.postgresId],
[mysql, mysql.mysqlId],
[mariadb, mariadb.mariadbId],
[mongo, mongo.mongoId],
[redis, redis.redisId],
[libsql, libsql.libsqlId],
] as const;
// Callers pass the id of any service type, so the owning table is unknown.
const findServiceOrganizationId = async (serviceId: string) => {
for (const [table, idColumn] of serviceTables) {
const [row] = await db
.select({ organizationId: projects.organizationId })
.from(table)
.innerJoin(
environments,
eq(table.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(eq(idColumn, serviceId));
if (row) {
return row.organizationId;
}
}
return undefined;
};
const findProjectOrganizationId = async (projectId: string) => {
const project = await db.query.projects.findFirst({
where: eq(projects.projectId, projectId),
columns: { organizationId: true },
});
return project?.organizationId;
};
const notFoundMessages = {
service: "Service not found",
project: "Project not found",
};
const assertOrganizationOwns = (
ctx: PermissionCtx,
resource: keyof typeof notFoundMessages,
ownerOrganizationId: string | undefined,
) => {
if (!ownerOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: notFoundMessages[resource],
});
}
if (ownerOrganizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: `You are not authorized to access this ${resource}`,
});
}
};
const assertServiceInOrganization = async (
ctx: PermissionCtx,
serviceId: string,
) =>
assertOrganizationOwns(
ctx,
"service",
await findServiceOrganizationId(serviceId),
);
const assertProjectInOrganization = async (
ctx: PermissionCtx,
projectId: string,
) =>
assertOrganizationOwns(
ctx,
"project",
await findProjectOrganizationId(projectId),
);
export const checkProjectAccess = async (
ctx: PermissionCtx,
action: "create" | "delete",
@@ -249,6 +344,7 @@ export const checkServicePermissionAndAccess = async (
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await assertServiceInOrganization(ctx, serviceId);
await checkPermission(ctx, permissions);
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedServices.includes(serviceId)) {
@@ -269,6 +365,13 @@ export const checkServiceAccess = async (
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
// For "create" the id is the target project, not an existing service.
if (action === "create") {
await assertProjectInOrganization(ctx, serviceId);
} else {
await assertServiceInOrganization(ctx, serviceId);
}
await checkPermission(ctx, { service: [action] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
@@ -323,6 +426,8 @@ export const checkEnvironmentCreationPermission = async (
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await assertProjectInOrganization(ctx, projectId);
await checkPermission(ctx, { environment: ["create"] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
+5 -2
View File
@@ -4,7 +4,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
@@ -24,7 +24,10 @@ export const runComposeBackup = async (
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix, databaseType, serviceName } = backup;
const destination = await findDestinationById(backup.destinationId);
const destination = await findDestinationByIdForOrganization(
backup.destinationId,
project.organizationId,
);
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
+5 -2
View File
@@ -3,7 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Libsql } from "@dokploy/server/services/libsql";
import { findProjectById } from "@dokploy/server/services/project";
@@ -23,6 +23,10 @@ export const runLibsqlBackup = async (
const { name, environmentId, appName } = libsql;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const destination = await findDestinationByIdForOrganization(
backup.destinationId,
project.organizationId,
);
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
@@ -30,7 +34,6 @@ export const runLibsqlBackup = async (
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
+5 -2
View File
@@ -3,7 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project";
@@ -24,7 +24,10 @@ export const runMariadbBackup = async (
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const destination = await findDestinationByIdForOrganization(
backup.destinationId,
project.organizationId,
);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
+5 -2
View File
@@ -3,7 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project";
@@ -21,7 +21,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const destination = await findDestinationByIdForOrganization(
backup.destinationId,
project.organizationId,
);
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
+5 -2
View File
@@ -3,7 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project";
@@ -21,7 +21,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const destination = await findDestinationByIdForOrganization(
backup.destinationId,
project.organizationId,
);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
@@ -3,7 +3,7 @@ import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import { findDestinationByIdForOrganization } from "@dokploy/server/services/destination";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
@@ -23,6 +23,10 @@ export const runPostgresBackup = async (
const { name, environmentId, appName } = postgres;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const destination = await findDestinationByIdForOrganization(
backup.destinationId,
project.organizationId,
);
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
@@ -30,7 +34,6 @@ export const runPostgresBackup = async (
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = await findDestinationById(backup.destinationId);
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
@@ -0,0 +1,30 @@
import { quote } from "shell-quote";
import { ExecError } from "../process/ExecError";
import { execAsync, execAsyncRemote } from "../process/execAsync";
const LOG_TAIL_LINES = 30;
const FALLBACK_MESSAGE = "Error building";
export const formatBuildLogTail = (log: string, maxLines = LOG_TAIL_LINES) =>
log.trim().split("\n").slice(-maxLines).join("\n").trim();
// ExecError.message no longer carries the build output; the log tail does.
export const getBuildErrorMessage = async (
error: unknown,
logPath: string,
serverId?: string | null,
): Promise<string> => {
if (!(error instanceof ExecError)) {
return (error instanceof Error && error.message) || FALLBACK_MESSAGE;
}
try {
const command = `tail -n ${LOG_TAIL_LINES} ${quote([logPath])}`;
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
return formatBuildLogTail(stdout) || FALLBACK_MESSAGE;
} catch {
return FALLBACK_MESSAGE;
}
};
@@ -18,6 +18,12 @@ interface CloneGitRepository {
outputPathOverride?: string;
}
// Header auth keeps the token out of build logs.
export const getGitTokenAuthConfig = (cloneUrl: string, token: string) => {
const credentials = Buffer.from(`oauth2:${token}`).toString("base64");
return `-c ${quote([`http.${new URL(cloneUrl).origin}/.extraHeader=Authorization: Basic ${credentials}`])}`;
};
export const cloneGitRepository = async ({
type = "application",
...entity
+3 -4
View File
@@ -8,6 +8,7 @@ import {
import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { getGitTokenAuthConfig } from "./git";
export const getErrorCloneRequirements = (entity: {
giteaRepository?: string | null;
@@ -99,14 +100,13 @@ export const refreshGiteaToken = async (giteaProviderId: string) => {
const buildGiteaCloneUrl = (
giteaUrl: string,
accessToken: string,
owner: string,
repository: string,
) => {
const protocol = giteaUrl.startsWith("http://") ? "http" : "https";
const baseUrl = giteaUrl.replace(/^https?:\/\//, "");
const repoClone = `${owner}/${repository}.git`;
const cloneUrl = `${protocol}://oauth2:${accessToken}@${baseUrl}/${repoClone}`;
const cloneUrl = `${protocol}://${baseUrl}/${repoClone}`;
return cloneUrl;
};
@@ -172,13 +172,12 @@ export const cloneGiteaRepository = async ({
const repoClone = `${giteaOwner}/${giteaRepository}.git`;
const cloneUrl = buildGiteaCloneUrl(
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl,
giteaProvider.accessToken!,
giteaOwner!,
giteaRepository!,
);
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
command += `git ${getGitTokenAuthConfig(cloneUrl, giteaProvider.accessToken!)} clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -8,6 +8,7 @@ import { TRPCError } from "@trpc/server";
import { Octokit } from "octokit";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getGitTokenAuthConfig } from "./git";
export const DEFAULT_GITHUB_URL = "https://github.com";
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
@@ -229,10 +230,10 @@ export const cloneGithubRepository = async ({
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
const cloneUrl = `${cloneBase.protocol}//${repoclone}`;
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
command += `git ${getGitTokenAuthConfig(cloneUrl, token)} clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -10,6 +10,7 @@ import type { InferResultType } from "@dokploy/server/types/with";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import type { z } from "zod";
import { getGitTokenAuthConfig } from "./git";
const refreshGitlabTokenIfExpired = async (gitlabProviderId: string) => {
const gitlabProvider = await findGitlabById(gitlabProviderId);
@@ -118,7 +119,7 @@ const getGitlabRepoClone = (
const getGitlabCloneUrl = (gitlab: GitlabInfo, repoClone: string) => {
const url = gitlab?.gitlabInternalUrl || gitlab?.gitlabUrl;
const isSecure = url?.startsWith("https://");
const cloneUrl = `http${isSecure ? "s" : ""}://oauth2:${gitlab?.accessToken}@${repoClone}`;
const cloneUrl = `http${isSecure ? "s" : ""}://${repoClone}`;
return cloneUrl;
};
@@ -172,7 +173,7 @@ export const cloneGitlabRepository = async ({
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
command += `git clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
command += `git ${getGitTokenAuthConfig(cloneUrl, String(gitlab?.accessToken))} clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
return command;
};
@@ -4,7 +4,10 @@ import {
createDeploymentVolumeBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findDestinationById } from "@dokploy/server/services/destination";
import {
findDestinationById,
findDestinationByIdForOrganization,
} from "@dokploy/server/services/destination";
import { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
import {
execAsync,
@@ -106,13 +109,17 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
const volumeBackup = await findVolumeBackupById(volumeBackupId);
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
const projectName = getProjectName(volumeBackup);
const organizationId = getOrganizationId(volumeBackup);
await findDestinationByIdForOrganization(
volumeBackup.destinationId,
organizationId,
);
const deployment = await createDeploymentVolumeBackup({
volumeBackupId: volumeBackup.volumeBackupId,
title: "Volume Backup",
description: "Volume Backup",
});
const projectName = getProjectName(volumeBackup);
const organizationId = getOrganizationId(volumeBackup);
try {
const command = await backupVolume(volumeBackup);