fix(ssh-key): reject passphrase-protected private keys at import time

This commit is contained in:
detail-app[bot] 2026-09-04 02:48:10 +00:00 committed by GitHub
parent 1572008cdf
commit f8da99fabc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 291 additions and 0 deletions

View File

@ -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<string, unknown>;
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();
});
});

View File

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

View File

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

View File

@ -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 <keyfile>`)."
: `Invalid private key: ${parsed.message}`,
};
}
return { ok: true };
};