From 0a3255d20d13a0f7af8c06c06f3a019b8a7c1185 Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:52:49 +0000 Subject: [PATCH] fix(templates): generate database credentials from a CSPRNG --- .../templates/password.template.test.ts | 67 +++++++++++++++++++ packages/server/src/templates/index.ts | 6 +- 2 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 apps/dokploy/__test__/templates/password.template.test.ts diff --git a/apps/dokploy/__test__/templates/password.template.test.ts b/apps/dokploy/__test__/templates/password.template.test.ts new file mode 100644 index 000000000..d8ffed988 --- /dev/null +++ b/apps/dokploy/__test__/templates/password.template.test.ts @@ -0,0 +1,67 @@ +import { randomInt } from "node:crypto"; +import { generatePassword } from "@dokploy/server/templates"; +import { describe, expect, it, vi } from "vitest"; + +// Wrap `node:crypto.randomInt` with a spy that delegates to the real CSPRNG +// so we can confirm `generatePassword` draws from it while keeping true +// CSPRNG output for every behavioral assertion. `vi.mock` is hoisted above +// the `import` statements automatically. +vi.mock("node:crypto", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + randomInt: vi.fn(actual.randomInt), + }; +}); + +describe("generatePassword", () => { + it("defaults to a 16-character string", () => { + expect(generatePassword()).toHaveLength(16); + }); + + it.each([6, 16, 32])( + "generates a string of length %d when requested", + (length) => { + expect(generatePassword(length)).toHaveLength(length); + }, + ); + + // `.toLowerCase()` is load-bearing: the function is dual-use and the + // app/service-name suffix callers (buildAppName, createPreviewDeployment) + // require DNS-label / Docker-Swarm-service-name-safe lowercase output. + it("produces only lowercase alphanumeric characters", () => { + for (let i = 0; i < 200; i++) { + expect(generatePassword()).toMatch(/^[a-z0-9]+$/); + } + }); + + // Credential material must come from a CSPRNG, not Math.random(). + // This guards against reintroducing the regression fixed here. + it("does not source randomness from Math.random()", () => { + const randomSpy = vi.spyOn(Math, "random"); + randomSpy.mockClear(); + + generatePassword(32); + + expect(randomSpy).not.toHaveBeenCalled(); + randomSpy.mockRestore(); + }); + + it("sources randomness from node:crypto (crypto.randomInt)", () => { + const randomIntSpy = randomInt as unknown as ReturnType; + randomIntSpy.mockClear(); + + const password = generatePassword(16); + + expect(randomIntSpy).toHaveBeenCalled(); + expect(password).toHaveLength(16); + }); + + it("produces distinct values across calls", () => { + const samples = new Set(); + for (let i = 0; i < 1000; i++) { + samples.add(generatePassword()); + } + expect(samples.size).toBe(1000); + }); +}); diff --git a/packages/server/src/templates/index.ts b/packages/server/src/templates/index.ts index c16f0624b..67c739adf 100644 --- a/packages/server/src/templates/index.ts +++ b/packages/server/src/templates/index.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes } from "node:crypto"; +import { createHmac, randomBytes, randomInt } from "node:crypto"; import { existsSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -61,9 +61,7 @@ export const generatePassword = (quantity = 16): string => { "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let password = ""; for (let i = 0; i < quantity; i++) { - password += characters.charAt( - Math.floor(Math.random() * characters.length), - ); + password += characters.charAt(randomInt(0, characters.length)); } return password.toLowerCase(); };