From 68ea9f7771afe6acca57032dc4328f93c4f25999 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 21:28:02 -0600 Subject: [PATCH 1/4] fix(security): redact git provider secrets from application.one response findApplicationById eagerly loads the github/gitlab/gitea/bitbucket relations (needed server-side to clone) including OAuth tokens, the GitHub App private key and webhook secret. application.one returned them to the client, exposing them to any member with service:read even when hasGitProviderAccess was false. Adds redactApplicationGitSecrets() in the application service (blanks the secret columns, immutably) and applies it to application.one. No client feature reads these secrets (verified in the frontend); server-side clone paths use findApplicationById directly, so behaviour is unchanged. Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f --- .../application-git-secret-redaction.test.ts | 70 +++++++++++++++++++ .../dokploy/server/api/routers/application.ts | 3 +- packages/server/src/services/application.ts | 47 +++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/__test__/application/application-git-secret-redaction.test.ts diff --git a/apps/dokploy/__test__/application/application-git-secret-redaction.test.ts b/apps/dokploy/__test__/application/application-git-secret-redaction.test.ts new file mode 100644 index 000000000..2866e8669 --- /dev/null +++ b/apps/dokploy/__test__/application/application-git-secret-redaction.test.ts @@ -0,0 +1,70 @@ +import { redactApplicationGitSecrets } from "@dokploy/server/services/application"; +import { describe, expect, it } from "vitest"; + +describe("redactApplicationGitSecrets (application.one secret disclosure guard)", () => { + it("blanks github secrets while keeping non-secret fields", () => { + const app = { + applicationId: "app-1", + github: { + githubId: "gh-1", + githubClientId: "public-client-id", + githubClientSecret: "SECRET", + githubPrivateKey: "-----BEGIN KEY-----", + githubWebhookSecret: "whsec", + githubInstallationId: "12345", + }, + }; + + const out = redactApplicationGitSecrets(app); + + expect(out.github.githubClientSecret).toBe(""); + expect(out.github.githubPrivateKey).toBe(""); + expect(out.github.githubWebhookSecret).toBe(""); + // Non-secret identifiers must survive so the UI can still show the link. + expect(out.github.githubClientId).toBe("public-client-id"); + expect(out.github.githubInstallationId).toBe("12345"); + expect(out.applicationId).toBe("app-1"); + }); + + it("blanks gitlab / gitea / bitbucket tokens and passwords", () => { + const app = { + gitlab: { secret: "s", accessToken: "at", refreshToken: "rt" }, + gitea: { clientSecret: "cs", accessToken: "at", refreshToken: "rt" }, + bitbucket: { appPassword: "pw", apiToken: "tok" }, + }; + + const out = redactApplicationGitSecrets(app); + + expect(out.gitlab).toMatchObject({ + secret: "", + accessToken: "", + refreshToken: "", + }); + expect(out.gitea).toMatchObject({ + clientSecret: "", + accessToken: "", + refreshToken: "", + }); + expect(out.bitbucket).toMatchObject({ appPassword: "", apiToken: "" }); + }); + + it("does not mutate the original application", () => { + const app = { github: { githubClientSecret: "SECRET" } }; + redactApplicationGitSecrets(app); + expect(app.github.githubClientSecret).toBe("SECRET"); + }); + + it("handles applications without any git provider relation", () => { + const app: { + applicationId: string; + github?: null; + gitlab?: null; + gitea?: null; + bitbucket?: null; + } = { applicationId: "app-2", github: null }; + expect(redactApplicationGitSecrets(app)).toMatchObject({ + applicationId: "app-2", + github: null, + }); + }); +}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index e1bbedcb9..722ffeedb 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -13,6 +13,7 @@ import { mechanizeDockerContainer, readConfig, readRemoteConfig, + redactApplicationGitSecrets, removeDeployments, removeDirectoryCode, removeMonitoringDirectory, @@ -183,7 +184,7 @@ export const applicationRouter = createTRPCRouter({ } return { - ...application, + ...redactApplicationGitSecrets(application), hasGitProviderAccess, unauthorizedProvider, }; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index c8ebb3be7..396313b52 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -122,6 +122,53 @@ export const findApplicationById = async (applicationId: string) => { return application; }; +/** + * Blanks the git-provider secret columns nested inside an application before it + * is returned to a client. `findApplicationById` eagerly loads the github / + * gitlab / gitea / bitbucket relations (needed server-side to clone), but their + * OAuth tokens, app private key and webhook secret must never reach the browser: + * no client feature reads them, and `application.one` exposed them to any member + * with `service:read` even without git-provider access. + */ +export const redactApplicationGitSecrets = < + T extends { + github?: Record | null; + gitlab?: Record | null; + gitea?: Record | null; + bitbucket?: Record | null; + }, +>( + application: T, +): T => { + const blank = ( + provider: Record | null | undefined, + fields: readonly string[], + ) => { + if (!provider) return provider; + const redacted = { ...provider }; + for (const field of fields) { + if (field in redacted) redacted[field] = ""; + } + return redacted; + }; + + return { + ...application, + github: blank(application.github, [ + "githubClientSecret", + "githubPrivateKey", + "githubWebhookSecret", + ]), + gitlab: blank(application.gitlab, ["secret", "accessToken", "refreshToken"]), + gitea: blank(application.gitea, [ + "clientSecret", + "accessToken", + "refreshToken", + ]), + bitbucket: blank(application.bitbucket, ["appPassword", "apiToken"]), + }; +}; + export const findApplicationByName = async (appName: string) => { const application = await db.query.applications.findFirst({ where: eq(applications.appName, appName), From 77384b218397a6fd9e17c2a6836d798bc804714e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:28:36 +0000 Subject: [PATCH 2/4] [autofix.ci] apply automated fixes --- packages/server/src/services/application.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 396313b52..975f04a0d 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -159,7 +159,11 @@ export const redactApplicationGitSecrets = < "githubPrivateKey", "githubWebhookSecret", ]), - gitlab: blank(application.gitlab, ["secret", "accessToken", "refreshToken"]), + gitlab: blank(application.gitlab, [ + "secret", + "accessToken", + "refreshToken", + ]), gitea: blank(application.gitea, [ "clientSecret", "accessToken", From c0afc48da8d535a99c8617db33fa7ee516d9d704 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 21:35:59 -0600 Subject: [PATCH 3/4] docs: trim block comment on redactApplicationGitSecrets --- packages/server/src/services/application.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 975f04a0d..38b1c172c 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -122,14 +122,8 @@ export const findApplicationById = async (applicationId: string) => { return application; }; -/** - * Blanks the git-provider secret columns nested inside an application before it - * is returned to a client. `findApplicationById` eagerly loads the github / - * gitlab / gitea / bitbucket relations (needed server-side to clone), but their - * OAuth tokens, app private key and webhook secret must never reach the browser: - * no client feature reads them, and `application.one` exposed them to any member - * with `service:read` even without git-provider access. - */ +// Blanks the nested git-provider secret columns before an application is sent to +// a client (server-side clone paths use findApplicationById directly). export const redactApplicationGitSecrets = < T extends { github?: Record | null; From ecbaf6060bf6d00491ee51086e28258979777226 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 21:43:51 -0600 Subject: [PATCH 4/4] refactor(security): exclude git provider secrets at query level in findApplicationById Simpler and consistent with the existing registry column exclusion in the same query: drop the secret columns from the nested github/gitlab/gitea/bitbucket relations via columns:{ ...: false } instead of a post-fetch redaction helper. Server-side clone paths re-fetch providers by id (find{Github,Gitlab,...}ById), so deployments are unaffected. Column names are validated at compile time by drizzle's typed columns config. Closes GHSA-hg9j-j5mc-phf5, GHSA-wx75-vxph-2m2f --- .../application-git-secret-redaction.test.ts | 70 ------------------- .../dokploy/server/api/routers/application.ts | 3 +- packages/server/src/services/application.ts | 67 +++++------------- 3 files changed, 19 insertions(+), 121 deletions(-) delete mode 100644 apps/dokploy/__test__/application/application-git-secret-redaction.test.ts diff --git a/apps/dokploy/__test__/application/application-git-secret-redaction.test.ts b/apps/dokploy/__test__/application/application-git-secret-redaction.test.ts deleted file mode 100644 index 2866e8669..000000000 --- a/apps/dokploy/__test__/application/application-git-secret-redaction.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { redactApplicationGitSecrets } from "@dokploy/server/services/application"; -import { describe, expect, it } from "vitest"; - -describe("redactApplicationGitSecrets (application.one secret disclosure guard)", () => { - it("blanks github secrets while keeping non-secret fields", () => { - const app = { - applicationId: "app-1", - github: { - githubId: "gh-1", - githubClientId: "public-client-id", - githubClientSecret: "SECRET", - githubPrivateKey: "-----BEGIN KEY-----", - githubWebhookSecret: "whsec", - githubInstallationId: "12345", - }, - }; - - const out = redactApplicationGitSecrets(app); - - expect(out.github.githubClientSecret).toBe(""); - expect(out.github.githubPrivateKey).toBe(""); - expect(out.github.githubWebhookSecret).toBe(""); - // Non-secret identifiers must survive so the UI can still show the link. - expect(out.github.githubClientId).toBe("public-client-id"); - expect(out.github.githubInstallationId).toBe("12345"); - expect(out.applicationId).toBe("app-1"); - }); - - it("blanks gitlab / gitea / bitbucket tokens and passwords", () => { - const app = { - gitlab: { secret: "s", accessToken: "at", refreshToken: "rt" }, - gitea: { clientSecret: "cs", accessToken: "at", refreshToken: "rt" }, - bitbucket: { appPassword: "pw", apiToken: "tok" }, - }; - - const out = redactApplicationGitSecrets(app); - - expect(out.gitlab).toMatchObject({ - secret: "", - accessToken: "", - refreshToken: "", - }); - expect(out.gitea).toMatchObject({ - clientSecret: "", - accessToken: "", - refreshToken: "", - }); - expect(out.bitbucket).toMatchObject({ appPassword: "", apiToken: "" }); - }); - - it("does not mutate the original application", () => { - const app = { github: { githubClientSecret: "SECRET" } }; - redactApplicationGitSecrets(app); - expect(app.github.githubClientSecret).toBe("SECRET"); - }); - - it("handles applications without any git provider relation", () => { - const app: { - applicationId: string; - github?: null; - gitlab?: null; - gitea?: null; - bitbucket?: null; - } = { applicationId: "app-2", github: null }; - expect(redactApplicationGitSecrets(app)).toMatchObject({ - applicationId: "app-2", - github: null, - }); - }); -}); diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 722ffeedb..e1bbedcb9 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -13,7 +13,6 @@ import { mechanizeDockerContainer, readConfig, readRemoteConfig, - redactApplicationGitSecrets, removeDeployments, removeDirectoryCode, removeMonitoringDirectory, @@ -184,7 +183,7 @@ export const applicationRouter = createTRPCRouter({ } return { - ...redactApplicationGitSecrets(application), + ...application, hasGitProviderAccess, unauthorizedProvider, }; diff --git a/packages/server/src/services/application.ts b/packages/server/src/services/application.ts index 38b1c172c..0f2e5a4fc 100644 --- a/packages/server/src/services/application.ts +++ b/packages/server/src/services/application.ts @@ -102,10 +102,24 @@ export const findApplicationById = async (applicationId: string) => { redirects: true, security: true, ports: true, - gitlab: true, - github: true, - bitbucket: true, - gitea: true, + gitlab: { + columns: { secret: false, accessToken: false, refreshToken: false }, + }, + github: { + columns: { + githubClientSecret: false, + githubPrivateKey: false, + githubWebhookSecret: false, + }, + }, + bitbucket: { columns: { appPassword: false, apiToken: false } }, + gitea: { + columns: { + clientSecret: false, + accessToken: false, + refreshToken: false, + }, + }, server: true, previewDeployments: true, registry: { columns: { password: false } }, @@ -122,51 +136,6 @@ export const findApplicationById = async (applicationId: string) => { return application; }; -// Blanks the nested git-provider secret columns before an application is sent to -// a client (server-side clone paths use findApplicationById directly). -export const redactApplicationGitSecrets = < - T extends { - github?: Record | null; - gitlab?: Record | null; - gitea?: Record | null; - bitbucket?: Record | null; - }, ->( - application: T, -): T => { - const blank = ( - provider: Record | null | undefined, - fields: readonly string[], - ) => { - if (!provider) return provider; - const redacted = { ...provider }; - for (const field of fields) { - if (field in redacted) redacted[field] = ""; - } - return redacted; - }; - - return { - ...application, - github: blank(application.github, [ - "githubClientSecret", - "githubPrivateKey", - "githubWebhookSecret", - ]), - gitlab: blank(application.gitlab, [ - "secret", - "accessToken", - "refreshToken", - ]), - gitea: blank(application.gitea, [ - "clientSecret", - "accessToken", - "refreshToken", - ]), - bitbucket: blank(application.bitbucket, ["appPassword", "apiToken"]), - }; -}; - export const findApplicationByName = async (appName: string) => { const application = await db.query.applications.findFirst({ where: eq(applications.appName, appName),