From b3cd3c5061b9e4f9a786dc4a048765f79bae2523 Mon Sep 17 00:00:00 2001 From: Narciso Date: Thu, 22 Oct 2026 15:34:13 -0400 Subject: [PATCH 1/2] fix: close cross-tenant, backup destination and token leak security issues - scope service permission checks to the active organization (#5635) - check backup and volume backup destinations against the organization, confine certificate paths to CERTIFICATES_PATH, validate compose branches (#5541) - send git provider tokens through an http extraHeader instead of the clone URL so submodule URLs and build logs never contain them (#5618) - send the build log tail in build failure notifications (#5557) --- .../backup-destination-organization.test.ts | 196 ++++++++++++++++++ ...un-backup-destination-organization.test.ts | 172 +++++++++++++++ ...me-backup-destination-organization.test.ts | 127 ++++++++++++ .../certificate-path-traversal.test.ts | 169 +++++++++++++++ .../compose/branch-validation.test.ts | 39 ++++ .../git-provider/clone-token-header.test.ts | 116 +++++++++++ .../git-provider/github-clone-host.test.ts | 20 +- .../notifications/build-error-message.test.ts | 101 +++++++++ .../permissions/cross-org-scope.test.ts | 188 +++++++++++++++++ .../permissions/service-access.test.ts | 36 ++-- apps/dokploy/server/api/routers/backup.ts | 20 +- apps/dokploy/server/api/routers/deployment.ts | 48 ++++- .../server/api/routers/volume-backups.ts | 13 +- packages/server/src/db/schema/certificate.ts | 2 +- packages/server/src/db/schema/compose.ts | 13 ++ packages/server/src/services/application.ts | 9 +- packages/server/src/services/certificate.ts | 22 +- packages/server/src/services/compose.ts | 9 +- packages/server/src/services/destination.ts | 14 ++ packages/server/src/services/permission.ts | 107 +++++++++- packages/server/src/utils/backups/compose.ts | 7 +- packages/server/src/utils/backups/libsql.ts | 7 +- packages/server/src/utils/backups/mariadb.ts | 7 +- packages/server/src/utils/backups/mongo.ts | 7 +- packages/server/src/utils/backups/mysql.ts | 7 +- packages/server/src/utils/backups/postgres.ts | 7 +- .../notifications/build-error-message.ts | 30 +++ packages/server/src/utils/providers/git.ts | 6 + packages/server/src/utils/providers/gitea.ts | 7 +- packages/server/src/utils/providers/github.ts | 5 +- packages/server/src/utils/providers/gitlab.ts | 5 +- .../server/src/utils/volume-backups/utils.ts | 13 +- 32 files changed, 1462 insertions(+), 67 deletions(-) create mode 100644 apps/dokploy/__test__/backups/backup-destination-organization.test.ts create mode 100644 apps/dokploy/__test__/backups/run-backup-destination-organization.test.ts create mode 100644 apps/dokploy/__test__/backups/volume-backup-destination-organization.test.ts create mode 100644 apps/dokploy/__test__/certificates/certificate-path-traversal.test.ts create mode 100644 apps/dokploy/__test__/compose/branch-validation.test.ts create mode 100644 apps/dokploy/__test__/git-provider/clone-token-header.test.ts create mode 100644 apps/dokploy/__test__/notifications/build-error-message.test.ts create mode 100644 apps/dokploy/__test__/permissions/cross-org-scope.test.ts create mode 100644 packages/server/src/utils/notifications/build-error-message.ts diff --git a/apps/dokploy/__test__/backups/backup-destination-organization.test.ts b/apps/dokploy/__test__/backups/backup-destination-organization.test.ts new file mode 100644 index 000000000..3e0b1b80e --- /dev/null +++ b/apps/dokploy/__test__/backups/backup-destination-organization.test.ts @@ -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[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) { + } +}; + +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); + }); +}); diff --git a/apps/dokploy/__test__/backups/run-backup-destination-organization.test.ts b/apps/dokploy/__test__/backups/run-backup-destination-organization.test.ts new file mode 100644 index 000000000..dc354df82 --- /dev/null +++ b/apps/dokploy/__test__/backups/run-backup-destination-organization.test.ts @@ -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"); + }); +}); diff --git a/apps/dokploy/__test__/backups/volume-backup-destination-organization.test.ts b/apps/dokploy/__test__/backups/volume-backup-destination-organization.test.ts new file mode 100644 index 000000000..78cd5e242 --- /dev/null +++ b/apps/dokploy/__test__/backups/volume-backup-destination-organization.test.ts @@ -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[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(); + }); +}); diff --git a/apps/dokploy/__test__/certificates/certificate-path-traversal.test.ts b/apps/dokploy/__test__/certificates/certificate-path-traversal.test.ts new file mode 100644 index 000000000..4c556603b --- /dev/null +++ b/apps/dokploy/__test__/certificates/certificate-path-traversal.test.ts @@ -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 }, + ); + }); +}); diff --git a/apps/dokploy/__test__/compose/branch-validation.test.ts b/apps/dokploy/__test__/compose/branch-validation.test.ts new file mode 100644 index 000000000..31acbd5f4 --- /dev/null +++ b/apps/dokploy/__test__/compose/branch-validation.test.ts @@ -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); + }); +}); diff --git a/apps/dokploy/__test__/git-provider/clone-token-header.test.ts b/apps/dokploy/__test__/git-provider/clone-token-header.test.ts new file mode 100644 index 000000000..784bd2316 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/clone-token-header.test.ts @@ -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[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} `); + }, + ); +}); diff --git a/apps/dokploy/__test__/git-provider/github-clone-host.test.ts b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts index 93e8d0049..446c4933e 100644 --- a/apps/dokploy/__test__/git-provider/github-clone-host.test.ts +++ b/apps/dokploy/__test__/git-provider/github-clone-host.test.ts @@ -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"); }); }); diff --git a/apps/dokploy/__test__/notifications/build-error-message.test.ts b/apps/dokploy/__test__/notifications/build-error-message.test.ts new file mode 100644 index 000000000..6812f7a38 --- /dev/null +++ b/apps/dokploy/__test__/notifications/build-error-message.test.ts @@ -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(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/cross-org-scope.test.ts b/apps/dokploy/__test__/permissions/cross-org-scope.test.ts new file mode 100644 index 000000000..2027ffbe0 --- /dev/null +++ b/apps/dokploy/__test__/permissions/cross-org-scope.test.ts @@ -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 = mockMemberData("owner"); + +// Two organizations; every id encodes the org that owns it. +const serviceOrgs: Record = { + "app-a": "org-a", + "app-b": "org-b", + "postgres-b": "org-b", +}; +const projectOrgs: Record = { + "project-a": "org-a", + "project-b": "org-b", +}; +const environmentOrgs: Record = { + "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(); + }); +}); diff --git a/apps/dokploy/__test__/permissions/service-access.test.ts b/apps/dokploy/__test__/permissions/service-access.test.ts index b3786807d..1c395626a 100644 --- a/apps/dokploy/__test__/permissions/service-access.test.ts +++ b/apps/dokploy/__test__/permissions/service-access.test.ts @@ -29,20 +29,32 @@ const mockMemberData = ( let memberToReturn: ReturnType = 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)), diff --git a/apps/dokploy/server/api/routers/backup.ts b/apps/dokploy/server/api/routers/backup.ts index 9bc73ce77..f134c376a 100644 --- a/apps/dokploy/server/api/routers/backup.ts +++ b/apps/dokploy/server/api/routers/backup.ts @@ -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); diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index dcc74226a..7926a22b2 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -5,8 +5,11 @@ import { findAllDeploymentsByComposeId, findAllDeploymentsByServerId, findAllDeploymentsCentralized, + findBackupById, findDeploymentById, + findPreviewDeploymentById, findScheduleById, + findVolumeBackupById, IS_CLOUD, removeDeployment, resolveServicePath, @@ -14,6 +17,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { + checkPermission, checkServicePermissionAndAccess, findMemberByUserId, } from "@dokploy/server/services/permission"; @@ -145,10 +149,48 @@ export const deploymentRouter = createTRPCRouter({ }); } } + } else if (input.type === "server") { + const targetServer = await findServerById(input.id); + if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + 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), diff --git a/apps/dokploy/server/api/routers/volume-backups.ts b/apps/dokploy/server/api/routers/volume-backups.ts index 8bce3ef31..29c586f50 100644 --- a/apps/dokploy/server/api/routers/volume-backups.ts +++ b/apps/dokploy/server/api/routers/volume-backups.ts @@ -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, diff --git a/packages/server/src/db/schema/certificate.ts b/packages/server/src/db/schema/certificate.ts index bf1da2296..ad96a3fa1 100644 --- a/packages/server/src/db/schema/certificate.ts +++ b/packages/server/src/db/schema/certificate.ts @@ -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), diff --git a/packages/server/src/db/schema/compose.ts b/packages/server/src/db/schema/compose.ts index 7f25724bd..1f0d20358 100644 --- a/packages/server/src/db/schema/compose.ts +++ b/packages/server/src/db/schema/compose.ts @@ -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) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 0f2e5a4fc..90dbe3834 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -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, }); diff --git a/packages/server/src/services/certificate.ts b/packages/server/src/services/certificate.ts index 1c6137506..fef996522 100644 --- a/packages/server/src/services/certificate.ts +++ b/packages/server/src/services/certificate.ts @@ -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"); diff --git a/packages/server/src/services/compose.ts b/packages/server/src/services/compose.ts index d6567b0dd..cbf5a8b50 100644 --- a/packages/server/src/services/compose.ts +++ b/packages/server/src/services/compose.ts @@ -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, }); diff --git a/packages/server/src/services/destination.ts b/packages/server/src/services/destination.ts index 9cbc9afa3..becb367bd 100644 --- a/packages/server/src/services/destination.ts +++ b/packages/server/src/services/destination.ts @@ -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, diff --git a/packages/server/src/services/permission.ts b/packages/server/src/services/permission.ts index 9de4152f5..b756221b2 100644 --- a/packages/server/src/services/permission.ts +++ b/packages/server/src/services/permission.ts @@ -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") { diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 7a5ba5535..e827dd96a 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -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}`; diff --git a/packages/server/src/utils/backups/libsql.ts b/packages/server/src/utils/backups/libsql.ts index c4ece6ed6..806342f28 100644 --- a/packages/server/src/utils/backups/libsql.ts +++ b/packages/server/src/utils/backups/libsql.ts @@ -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 { diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 4a3f7dc26..34919dc6d 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -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({ diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 6b80ccc86..8f94723eb 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -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({ diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index 2ac9228be..04a289766 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -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({ diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index de7ef5500..2fe72bfd3 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -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 { diff --git a/packages/server/src/utils/notifications/build-error-message.ts b/packages/server/src/utils/notifications/build-error-message.ts new file mode 100644 index 000000000..3dd51f94b --- /dev/null +++ b/packages/server/src/utils/notifications/build-error-message.ts @@ -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 => { + 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; + } +}; diff --git a/packages/server/src/utils/providers/git.ts b/packages/server/src/utils/providers/git.ts index 5ccbd6412..8525b7f56 100644 --- a/packages/server/src/utils/providers/git.ts +++ b/packages/server/src/utils/providers/git.ts @@ -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 diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index 1c6ae3a83..dee9c5844 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -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; }; diff --git a/packages/server/src/utils/providers/github.ts b/packages/server/src/utils/providers/github.ts index d4becec0e..15bfae94f 100644 --- a/packages/server/src/utils/providers/github.ts +++ b/packages/server/src/utils/providers/github.ts @@ -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; }; diff --git a/packages/server/src/utils/providers/gitlab.ts b/packages/server/src/utils/providers/gitlab.ts index 1221fd5a4..1379c289c 100644 --- a/packages/server/src/utils/providers/gitlab.ts +++ b/packages/server/src/utils/providers/gitlab.ts @@ -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; }; diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index 8edca6cf6..9dd6996e4 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -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); From 9b97af0d03a9f8fff2c6d57245990b4e30f56b44 Mon Sep 17 00:00:00 2001 From: Narciso Date: Thu, 22 Oct 2026 15:51:23 -0400 Subject: [PATCH 2/2] fix(deployment): restrict server deployment history to accessible servers --- apps/dokploy/server/api/routers/deployment.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 7926a22b2..f2b47097d 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -10,6 +10,7 @@ import { findPreviewDeploymentById, findScheduleById, findVolumeBackupById, + getAccessibleServerIds, IS_CLOUD, removeDeployment, resolveServicePath, @@ -150,8 +151,8 @@ export const deploymentRouter = createTRPCRouter({ } } } else if (input.type === "server") { - const targetServer = await findServerById(input.id); - if (targetServer.organizationId !== ctx.session.activeOrganizationId) { + 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.",