diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx index 81a8ca918a..52b1d13514 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-bitbucket-provider.tsx @@ -58,7 +58,10 @@ const BitbucketProviderSchema = z.object({ slug: z.string().optional(), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().optional(), diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx index c66a0e43f1..28464ee3d7 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-git-provider.tsx @@ -42,7 +42,10 @@ const GitProviderSchema = z.object({ repositoryURL: z.string().min(1, { message: "Repository URL is required", }), - branch: z.string().min(1, "Branch required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), sshKey: z.string().optional(), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false), diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx index 25a4716d56..0781f55a8e 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitea-provider.tsx @@ -73,7 +73,10 @@ const GiteaProviderSchema = z.object({ owner: z.string().min(1, "Owner is required"), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), giteaId: z.string().min(1, "Gitea Provider is required"), watchPaths: z.array(z.string()).default([]), enableSubmodules: z.boolean().optional(), diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx index e3571dcdd5..b4f069ee21 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-github-provider.tsx @@ -56,7 +56,10 @@ const GithubProviderSchema = z.object({ owner: z.string().min(1, "Owner is required"), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), githubId: z.string().min(1, "Github Provider is required"), watchPaths: z.array(z.string()).optional(), triggerType: z.enum(["push", "tag"]).default("push"), diff --git a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx index dad24281ed..d867654b52 100644 --- a/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx +++ b/apps/dokploy/components/dashboard/application/general/generic/save-gitlab-provider.tsx @@ -59,7 +59,10 @@ const GitlabProviderSchema = z.object({ id: z.number().nullable(), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), gitlabId: z.string().min(1, "Gitlab Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false), diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx index 6770308790..745f72d3bd 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-bitbucket-provider-compose.tsx @@ -58,7 +58,10 @@ const BitbucketProviderSchema = z.object({ slug: z.string().optional(), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false), diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx index a6d2f93df9..7ebb7edb94 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-git-provider-compose.tsx @@ -42,7 +42,10 @@ const GitProviderSchema = z.object({ repositoryURL: z.string().min(1, { message: "Repository URL is required", }), - branch: z.string().min(1, "Branch required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), sshKey: z.string().optional(), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false), diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx index 7e931d82f3..7515af723a 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitea-provider-compose.tsx @@ -58,7 +58,10 @@ const GiteaProviderSchema = z.object({ owner: z.string().min(1, "Owner is required"), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), giteaId: z.string().min(1, "Gitea Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false), diff --git a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx index d58469db08..a81774fec9 100644 --- a/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx +++ b/apps/dokploy/components/dashboard/compose/general/generic/save-gitlab-provider-compose.tsx @@ -59,7 +59,10 @@ const GitlabProviderSchema = z.object({ gitlabPathNamespace: z.string().min(1), }) .required(), - branch: z.string().min(1, "Branch is required").regex(VALID_BRANCH_REGEX, "Invalid branch name"), + branch: z + .string() + .min(1, "Branch is required") + .regex(VALID_BRANCH_REGEX, "Invalid branch name"), gitlabId: z.string().min(1, "Gitlab Provider is required"), watchPaths: z.array(z.string()).optional(), enableSubmodules: z.boolean().default(false), diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index 1a1a6de9e9..d30ff70545 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -14,6 +14,7 @@ "wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts", "reset-password": "node -r dotenv/config dist/reset-password.mjs", "reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs", + "migrate-auth-secret": "tsx -r dotenv/config scripts/migrate-auth-secret.ts", "dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ", "studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts", "migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts", diff --git a/apps/dokploy/scripts/migrate-auth-secret.ts b/apps/dokploy/scripts/migrate-auth-secret.ts new file mode 100644 index 0000000000..5a71678d9a --- /dev/null +++ b/apps/dokploy/scripts/migrate-auth-secret.ts @@ -0,0 +1,97 @@ +/** + * Use this command to automatically migrate the auth secret: curl -sSL https://dokploy.com/security/0.29.3.sh | bash + * Migration script: re-encrypt 2FA secrets after rotating BETTER_AUTH_SECRET. + * + * Usage: + * OLD_SECRET= NEW_SECRET= npx tsx apps/dokploy/scripts/migrate-auth-secret.ts + * + * Both OLD_SECRET and NEW_SECRET are required. + * Run this BEFORE restarting Dokploy with the new secret. + */ +import { db } from "@dokploy/server/db"; +import { twoFactor } from "@dokploy/server/db/schema"; +import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; +import { eq } from "drizzle-orm"; + +const OLD_SECRET = process.env.OLD_SECRET as string; +const NEW_SECRET = process.env.NEW_SECRET as string; + +if (!OLD_SECRET || !NEW_SECRET) { + console.error( + "❌ OLD_SECRET and NEW_SECRET environment variables are required.", + ); + console.error( + " Usage: OLD_SECRET= NEW_SECRET= npx tsx apps/dokploy/scripts/migrate-auth-secret.ts", + ); + process.exit(1); +} + +if (OLD_SECRET === NEW_SECRET) { + console.error("❌ OLD_SECRET and NEW_SECRET must be different."); + process.exit(1); +} + +async function reEncrypt( + value: string, + oldSecret: string, + newSecret: string, +): Promise { + const plaintext = await symmetricDecrypt({ key: oldSecret, data: value }); + return symmetricEncrypt({ key: newSecret, data: plaintext }); +} + +async function main() { + console.log("🔍 Fetching 2FA records..."); + const records = await db.select().from(twoFactor); + + if (records.length === 0) { + console.log("✅ No 2FA records found, nothing to migrate."); + return; + } + + console.log(`📦 Found ${records.length} 2FA record(s) to migrate.`); + + let migrated = 0; + let failed = 0; + + await db.transaction(async (tx) => { + for (const record of records) { + try { + const [newSecret, newBackupCodes] = await Promise.all([ + reEncrypt(record.secret, OLD_SECRET, NEW_SECRET), + reEncrypt(record.backupCodes, OLD_SECRET, NEW_SECRET), + ]); + + await tx + .update(twoFactor) + .set({ secret: newSecret, backupCodes: newBackupCodes }) + .where(eq(twoFactor.id, record.id)); + + migrated++; + } catch (err) { + console.error( + `❌ Failed to migrate record ${record.id} (userId: ${record.userId}):`, + err, + ); + failed++; + throw err; // rollback the whole transaction + } + } + }); + + console.log(`✅ Migrated ${migrated} record(s) successfully.`); + + if (failed > 0) { + console.error( + `❌ ${failed} record(s) failed — transaction was rolled back.`, + ); + process.exit(1); + } else { + process.exit(0); + } +} + +main().catch((err) => { + console.error("❌ Migration failed:", err); + process.exit(1); +}); diff --git a/apps/dokploy/server/wss/listen-deployment.ts b/apps/dokploy/server/wss/listen-deployment.ts index 0548004be6..d01c4d6d4e 100644 --- a/apps/dokploy/server/wss/listen-deployment.ts +++ b/apps/dokploy/server/wss/listen-deployment.ts @@ -72,7 +72,7 @@ export const setupDeploymentLogsWebSocketServer = ( sshClient .on("ready", () => { const encodedPath = encodeBase64(logPath); - const command = `tail -n +1 -f "$(echo '${encodedPath}' | base64 -d)"`; + const command = `tail -n +1 -f "$(echo '${encodedPath}' | base64 -d)"`; sshClient!.exec(command, (err, stream) => { if (err) { diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index f440215aef..706a0dbecb 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -83,11 +83,6 @@ const getDockerConfig = (): Docker => { export const docker = getDockerConfig(); -// When not set, use the legacy default so 2FA remains working for users who -// enabled it before BETTER_AUTH_SECRET was introduced. -export const BETTER_AUTH_SECRET = - process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789"; - export const paths = (isServer = false) => { const BASE_PATH = isServer || process.env.NODE_ENV === "production" diff --git a/packages/server/src/db/constants.ts b/packages/server/src/db/constants.ts index c4396726e8..a8867c62c8 100644 --- a/packages/server/src/db/constants.ts +++ b/packages/server/src/db/constants.ts @@ -9,7 +9,7 @@ export const { POSTGRES_PORT = "5432", } = process.env; -function readSecret(path: string): string { +export function readSecret(path: string): string { try { return fs.readFileSync(path, "utf8").trim(); } catch { diff --git a/packages/server/src/lib/auth-secret.ts b/packages/server/src/lib/auth-secret.ts new file mode 100644 index 0000000000..8e709758fd --- /dev/null +++ b/packages/server/src/lib/auth-secret.ts @@ -0,0 +1,28 @@ +import { readSecret } from "../db/constants"; + +const HARDCODED_LEGACY_SECRET = "better-auth-secret-123456789"; + +const { BETTER_AUTH_SECRET, BETTER_AUTH_SECRET_FILE } = process.env; + +function resolveBetterAuthSecret(): string { + if (BETTER_AUTH_SECRET) { + return BETTER_AUTH_SECRET; + } + if (BETTER_AUTH_SECRET_FILE) { + return readSecret(BETTER_AUTH_SECRET_FILE); + } + if (process.env.NODE_ENV !== "test") { + console.warn(` +⚠️ [DEPRECATED AUTH CONFIG] +BETTER_AUTH_SECRET is not set via environment variable or Docker secret. +Falling back to the insecure hardcoded default — this is a CRITICAL SECURITY RISK. +This mode WILL BE REMOVED in a future release. + +Please migrate to Docker Secrets: + curl -sSL https://dokploy.com/security/0.29.3.sh | bash +`); + } + return HARDCODED_LEGACY_SECRET; +} + +export const betterAuthSecret = resolveBetterAuthSecret(); diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index 069be48cc8..c8dbf18073 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -7,7 +7,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { APIError } from "better-auth/api"; import { admin, organization, twoFactor } from "better-auth/plugins"; import { and, desc, eq } from "drizzle-orm"; -import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants"; +import { IS_CLOUD } from "../constants"; import { db } from "../db"; import * as schema from "../db/schema"; import { @@ -27,6 +27,7 @@ import { } from "../verification/send-verification-email"; import { getPublicIpWithFallback } from "../wss/utils"; import { ac, adminRole, memberRole, ownerRole } from "./access-control"; +import { betterAuthSecret } from "./auth-secret"; const { handler, api } = betterAuth({ database: drizzleAdapter(db, { @@ -38,8 +39,9 @@ const { handler, api } = betterAuth({ "/organization/create", "/organization/update", "/organization/delete", + ...(!IS_CLOUD ? ["/verify-email"] : []), ], - secret: BETTER_AUTH_SECRET, + secret: betterAuthSecret, ...(!IS_CLOUD ? { advanced: { diff --git a/packages/server/src/services/docker.ts b/packages/server/src/services/docker.ts index 902041c53a..226230fbcf 100644 --- a/packages/server/src/services/docker.ts +++ b/packages/server/src/services/docker.ts @@ -670,7 +670,9 @@ export const uploadFileToContainer = async ( } if (!destinationPathRegex.test(destinationPath)) { - throw new Error("Invalid destination path: shell metacharacters are not allowed"); + throw new Error( + "Invalid destination path: shell metacharacters are not allowed", + ); } const normalizedPath = destinationPath.startsWith("/")