From 439eee45edf8cd4c0a886984b6115922c87ff4b2 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 21:14:10 -0600 Subject: [PATCH 1/2] fix(security): redact SSH private key from server read responses findServerById eagerly loads the sshKey relation (needed for server-side SSH operations) including the plaintext privateKey. server.one and server.remove returned that record to the client, exposing the private key to any member with server:read regardless of canAccessToSSHKeys. Adds redactServerSshKey() in the server service and applies it to the server.one and server.remove responses. No client feature consumes the private key (the SSH key management UI uses the dedicated sshKey router), and server-side callers keep using findServerById directly, so behaviour is unchanged. Closes GHSA-w9cp-jqfw-4xj9 --- .../server/server-sshkey-redaction.test.ts | 43 +++++++++++++++++++ apps/dokploy/server/api/routers/server.ts | 5 ++- packages/server/src/services/server.ts | 21 +++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 apps/dokploy/__test__/server/server-sshkey-redaction.test.ts 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..001909955 --- /dev/null +++ b/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts @@ -0,0 +1,43 @@ +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 an sshKey property at all", () => { + const server = { 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), From 117cfa1a8999f27315c3d0cbfe7069e1bce82fbc Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 21:18:57 -0600 Subject: [PATCH 2/2] test(types): annotate server record without sshKey relation in redaction test --- apps/dokploy/__test__/server/server-sshkey-redaction.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts b/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts index 001909955..90f73a7da 100644 --- a/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts +++ b/apps/dokploy/__test__/server/server-sshkey-redaction.test.ts @@ -36,8 +36,9 @@ describe("redactServerSshKey (server SSH private key disclosure guard)", () => { expect(redactServerSshKey(server)).toEqual(server); }); - it("handles a record without an sshKey property at all", () => { - const server = { serverId: "srv-3" }; + 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); }); });