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
This commit is contained in:
Mauricio Siu
2026-07-19 21:14:10 -06:00
parent 74811073f6
commit 439eee45ed
3 changed files with 67 additions and 2 deletions

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

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