diff --git a/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts b/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts new file mode 100644 index 000000000..90f73a7da --- /dev/null +++ b/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts @@ -0,0 +1,44 @@ +import { redactServerSshKey } from "@dokploy/server/services/server"; +import { describe, expect, it } from "vitest"; + +describe("redactServerSshKey (server SSH private key disclosure guard)", () => { + it("blanks the private key while keeping the rest of the ssh key intact", () => { + const server = { + serverId: "srv-1", + name: "prod", + sshKey: { + sshKeyId: "key-1", + publicKey: "ssh-ed25519 AAAA...", + privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n", + }, + }; + + const redacted = redactServerSshKey(server); + + expect(redacted.sshKey.privateKey).toBe(""); + // Non-secret fields and the surrounding record must survive untouched. + expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA..."); + expect(redacted.serverId).toBe("srv-1"); + expect(redacted.name).toBe("prod"); + }); + + it("does not mutate the original record", () => { + const server = { + serverId: "srv-1", + sshKey: { privateKey: "top-secret" }, + }; + redactServerSshKey(server); + expect(server.sshKey.privateKey).toBe("top-secret"); + }); + + it("is a no-op when the server has no ssh key", () => { + const server = { serverId: "srv-2", sshKey: null }; + expect(redactServerSshKey(server)).toEqual(server); + }); + + it("handles a record without a loaded sshKey relation", () => { + // e.g. server.update returns the plain row where sshKey is not populated. + const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" }; + expect(redactServerSshKey(server)).toEqual(server); + }); +}); diff --git a/apps/dokploy/server/api/routers/server.ts b/apps/dokploy/server/api/routers/server.ts index 0233173fa..7a4b818f3 100644 --- a/apps/dokploy/server/api/routers/server.ts +++ b/apps/dokploy/server/api/routers/server.ts @@ -9,6 +9,7 @@ import { getPublicIpWithFallback, haveActiveServices, IS_CLOUD, + redactServerSshKey, removeDeploymentsByServerId, serverAudit, serverSetup, @@ -105,7 +106,7 @@ export const serverRouter = createTRPCRouter({ }); } - return server; + return redactServerSshKey(server); }), getDefaultCommand: withPermission("server", "read") .input(apiFindOneServer) @@ -436,7 +437,7 @@ export const serverRouter = createTRPCRouter({ await updateServersBasedOnQuantity(admin.id, admin.serversQuantity); } - return currentServer; + return redactServerSshKey(currentServer); } catch (error) { throw error; } diff --git a/packages/server/src/services/server.ts b/packages/server/src/services/server.ts index 3057e4ddb..b58745f9a 100644 --- a/packages/server/src/services/server.ts +++ b/packages/server/src/services/server.ts @@ -53,6 +53,27 @@ export const findServerById = async (serverId: string) => { return currentServer; }; +/** + * Removes the SSH private key material from a server record before it is sent + * to a client. `findServerById` eagerly loads the `sshKey` relation (needed for + * server-side SSH operations), but the private key must never leave the server: + * no client feature consumes it, and returning it exposed it to any member with + * only `server:read`. Server-side callers keep using `findServerById` directly. + */ +export const redactServerSshKey = < + T extends { sshKey?: { privateKey: string } | null }, +>( + serverRecord: T, +): T => { + if (!serverRecord.sshKey) { + return serverRecord; + } + return { + ...serverRecord, + sshKey: { ...serverRecord.sshKey, privateKey: "" }, + }; +}; + export const findServersByUserId = async (userId: string) => { const orgs = await db.query.organization.findMany({ where: eq(organization.ownerId, userId),