mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-21 13:55:33 +02:00
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
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<string, unknown> | null;
|
||||
gitlab?: Record<string, unknown> | null;
|
||||
gitea?: Record<string, unknown> | null;
|
||||
bitbucket?: Record<string, unknown> | null;
|
||||
},
|
||||
>(
|
||||
application: T,
|
||||
): T => {
|
||||
const blank = (
|
||||
provider: Record<string, unknown> | 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),
|
||||
|
||||
Reference in New Issue
Block a user