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),