mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
fix(ssh-key): reject passphrase-protected private keys at import time
This commit is contained in:
parent
1572008cdf
commit
f8da99fabc
140
apps/dokploy/__test__/api/ssh-key-router-integration.test.ts
Normal file
140
apps/dokploy/__test__/api/ssh-key-router-integration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
120
apps/dokploy/__test__/api/ssh-key-validation.test.ts
Normal file
120
apps/dokploy/__test__/api/ssh-key-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user