diff --git a/apps/dokploy/__test__/api/ssh-key-router-integration.test.ts b/apps/dokploy/__test__/api/ssh-key-router-integration.test.ts new file mode 100644 index 000000000..ece1d191d --- /dev/null +++ b/apps/dokploy/__test__/api/ssh-key-router-integration.test.ts @@ -0,0 +1,140 @@ +import { TRPCError } from "@trpc/server"; +import * as ssh2 from "ssh2"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/services/permission", () => ({ + checkPermission: vi.fn().mockResolvedValue(undefined), +})); + +const auditMock = vi.fn().mockResolvedValue(undefined); +vi.mock("@/server/api/utils/audit", () => ({ + audit: auditMock, +})); + +const createSshKeyMock = vi.fn().mockResolvedValue(undefined); +vi.mock("@dokploy/server/services/ssh-key", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, createSshKey: createSshKeyMock }; +}); + +const { sshRouter } = await import("@/server/api/routers/ssh-key"); + +const plainPair = ssh2.utils.generateKeyPairSync("ed25519", { + comment: "test", +}); +const encPair = ssh2.utils.generateKeyPairSync("ed25519", { + passphrase: "testpass", + cipher: "aes256-ctr", + rounds: 16, + comment: "test", +}); + +const malformedBody = `${"A".repeat(64)}=`; +const malformedPrivKey = [ + "-----BEGIN OPENSSH PRIVATE KEY-----", + malformedBody, + "-----END OPENSSH PRIVATE KEY-----", + "", +].join("\n"); + +const buildInput = (privateKey: string) => ({ + name: "my-key", + description: "a test key", + publicKey: plainPair.public, + privateKey, + organizationId: "input-org-id", +}); + +const ctx = { + session: { + activeOrganizationId: "ctx-org-id", + user: { id: "u1" }, + }, + user: { + id: "u1", + email: "a@b.c", + role: "owner", + ownerId: "o1", + enableEnterpriseFeatures: false, + isValidEnterpriseLicense: false, + }, + db: {}, + req: {}, + res: {}, +} as const; + +const caller = sshRouter.createCaller(ctx as never); + +const reset = () => { + createSshKeyMock.mockClear(); + auditMock.mockClear(); +}; + +describe("ssh.create tRPC router integration (parseability gatekeeper)", () => { + beforeEach(reset); + + it("rejects an encrypted OpenSSH ed25519 key with the passphrase-specific BAD_REQUEST message (and never persists / audits)", async () => { + let thrown: unknown; + try { + await caller.create(buildInput(encPair.private)); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(TRPCError); + expect((thrown as TRPCError).code).toBe("BAD_REQUEST"); + expect((thrown as TRPCError).message).toContain( + "Passphrase-protected SSH keys are not supported", + ); + expect((thrown as TRPCError).message).toContain("ssh-keygen -p"); + expect((thrown as TRPCError).message).not.toBe( + "Error creating the SSH key", + ); + expect(createSshKeyMock).not.toHaveBeenCalled(); + expect(auditMock).not.toHaveBeenCalled(); + }); + + it("accepts a valid unencrypted OpenSSH ed25519 key, persists via createSshKey with the session organizationId, and audits once", async () => { + await caller.create(buildInput(plainPair.private)); + + expect(createSshKeyMock).toHaveBeenCalledTimes(1); + expect(createSshKeyMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "my-key", + publicKey: plainPair.public, + privateKey: plainPair.private, + organizationId: "ctx-org-id", + }), + ); + expect(auditMock).toHaveBeenCalledTimes(1); + expect(auditMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "create", + resourceType: "sshKey", + resourceName: "my-key", + }), + ); + }); + + it("rejects a regex-passing but unparseable key with a generic 'Invalid private key:' message (and never persists / audits)", async () => { + let thrown: unknown; + try { + await caller.create(buildInput(malformedPrivKey)); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(TRPCError); + expect((thrown as TRPCError).code).toBe("BAD_REQUEST"); + expect((thrown as TRPCError).message).toContain("Invalid private key:"); + expect((thrown as TRPCError).message).not.toBe( + "Error creating the SSH key", + ); + expect(createSshKeyMock).not.toHaveBeenCalled(); + expect(auditMock).not.toHaveBeenCalled(); + }); + + it("rejects a non-PEM string at the Zod input layer before the handler runs", async () => { + await expect(caller.create(buildInput("just some text"))).rejects.toThrow(); + expect(createSshKeyMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dokploy/__test__/api/ssh-key-validation.test.ts b/apps/dokploy/__test__/api/ssh-key-validation.test.ts new file mode 100644 index 000000000..366e3b4ca --- /dev/null +++ b/apps/dokploy/__test__/api/ssh-key-validation.test.ts @@ -0,0 +1,120 @@ +import { generateKeyPairSync as cryptoGenerateKeyPairSync } from "node:crypto"; +import { sshKeyCreate } from "@dokploy/server/db/validations"; +import { validateSshPrivateKeyParseable } from "@dokploy/server/utils/filesystem/ssh"; +import * as ssh2 from "ssh2"; +import { describe, expect, it } from "vitest"; + +const encryptedEd25519 = ssh2.utils.generateKeyPairSync("ed25519", { + passphrase: "testpass", + cipher: "aes256-ctr", + rounds: 16, + comment: "test", +}).private; + +const plainEd25519 = ssh2.utils.generateKeyPairSync("ed25519", { + comment: "test", +}).private; + +const encryptedOpenSshRsa = ssh2.utils.generateKeyPairSync("rsa", { + bits: 2048, + passphrase: "testpass", + cipher: "aes256-ctr", + rounds: 16, + comment: "test", +}).private; + +const plainOpenSshRsa = ssh2.utils.generateKeyPairSync("rsa", { + bits: 2048, + comment: "test", +}).private; + +const plainPkcs1Rsa = cryptoGenerateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, +}).privateKey; + +const malformedOpenSshKey = [ + "-----BEGIN OPENSSH PRIVATE KEY-----", + "not-valid-base64-payload-!!!", + "-----END OPENSSH PRIVATE KEY-----", + "", +].join("\n"); + +describe("validateSshPrivateKeyParseable (server-side SSH key parseability gatekeeper)", () => { + it("accepts an unencrypted OpenSSH ed25519 private key", () => { + expect(validateSshPrivateKeyParseable(plainEd25519)).toEqual({ ok: true }); + }); + + it("accepts an unencrypted OpenSSH RSA private key", () => { + expect(validateSshPrivateKeyParseable(plainOpenSshRsa)).toEqual({ + ok: true, + }); + }); + + it("accepts an unencrypted PKCS#1 RSA private key (regression: non-OpenSSH formats still work)", () => { + expect(validateSshPrivateKeyParseable(plainPkcs1Rsa)).toEqual({ + ok: true, + }); + }); + + it("rejects an encrypted OpenSSH ed25519 key (the reported bug) with a passphrase-specific message", () => { + const result = validateSshPrivateKeyParseable(encryptedEd25519); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.encrypted).toBe(true); + expect(result.message).toContain( + "Passphrase-protected SSH keys are not supported", + ); + expect(result.message).toContain("ssh-keygen -p"); + } + }); + + it("rejects an encrypted OpenSSH RSA key (bug scope is all OpenSSH-format encrypted keys, not just ed25519)", () => { + const result = validateSshPrivateKeyParseable(encryptedOpenSshRsa); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.encrypted).toBe(true); + expect(result.message).toContain( + "Passphrase-protected SSH keys are not supported", + ); + } + }); + + it("rejects a malformed OpenSSH key as a generic invalid key (not encrypted)", () => { + const result = validateSshPrivateKeyParseable(malformedOpenSshKey); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.encrypted).toBe(false); + expect(result.message).toContain("Invalid private key:"); + } + }); + + it("does not mutate or consume the input private key", () => { + const before = encryptedEd25519; + validateSshPrivateKeyParseable(encryptedEd25519); + expect(encryptedEd25519).toBe(before); + }); +}); + +describe("sshKeyCreate privateKey refine (regex-only by design; the server-side parseKey check is the real gatekeeper)", () => { + it("accepts a valid unencrypted OpenSSH ed25519 key", () => { + const result = sshKeyCreate.shape.privateKey.safeParse(plainEd25519); + expect(result.success).toBe(true); + }); + + it("accepts an encrypted OpenSSH ed25519 key, proving the regex cannot detect encryption", () => { + const result = sshKeyCreate.shape.privateKey.safeParse(encryptedEd25519); + expect(result.success).toBe(true); + }); + + it("accepts an encrypted OpenSSH RSA key, proving the regex cannot detect encryption for any OpenSSH-format key", () => { + const result = sshKeyCreate.shape.privateKey.safeParse(encryptedOpenSshRsa); + expect(result.success).toBe(true); + }); + + it("rejects a malformed private key at the format level", () => { + const result = sshKeyCreate.shape.privateKey.safeParse("just some text"); + expect(result.success).toBe(false); + }); +}); diff --git a/apps/dokploy/server/api/routers/ssh-key.ts b/apps/dokploy/server/api/routers/ssh-key.ts index aa758a0ea..a573d5fcd 100644 --- a/apps/dokploy/server/api/routers/ssh-key.ts +++ b/apps/dokploy/server/api/routers/ssh-key.ts @@ -4,6 +4,7 @@ import { generateSSHKey, removeSSHKeyById, updateSSHKeyById, + validateSshPrivateKeyParseable, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; @@ -27,6 +28,15 @@ export const sshRouter = createTRPCRouter({ create: withPermission("sshKeys", "create") .input(apiCreateSshKey) .mutation(async ({ input, ctx }) => { + const privateKeyValidation = validateSshPrivateKeyParseable( + input.privateKey, + ); + if (!privateKeyValidation.ok) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: privateKeyValidation.message, + }); + } try { await createSshKey({ ...input, diff --git a/packages/server/src/utils/filesystem/ssh.ts b/packages/server/src/utils/filesystem/ssh.ts index bebe64126..054e1ca00 100644 --- a/packages/server/src/utils/filesystem/ssh.ts +++ b/packages/server/src/utils/filesystem/ssh.ts @@ -24,3 +24,24 @@ export const generateSSHKey = async (type: "rsa" | "ed25519" = "rsa") => { throw error; } }; + +export type SshPrivateKeyValidation = + | { ok: true } + | { ok: false; encrypted: boolean; message: string }; + +export const validateSshPrivateKeyParseable = ( + privateKey: string, +): SshPrivateKeyValidation => { + const parsed = ssh2.utils.parseKey(privateKey); + if (parsed instanceof Error) { + const encrypted = /encrypt/i.test(parsed.message); + return { + ok: false, + encrypted, + message: encrypted + ? "Passphrase-protected SSH keys are not supported. Remove the passphrase before importing the key (e.g. `ssh-keygen -p -f `)." + : `Invalid private key: ${parsed.message}`, + }; + } + return { ok: true }; +};