mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-15 20:25:23 +02:00
Merge pull request #4374 from Dokploy/fix/better-auth-secret-hardcoded
fix(security): replace hardcoded BETTER_AUTH_SECRET with Docker secret support
This commit is contained in:
@@ -58,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.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"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
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(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.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"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).default([]),
|
watchPaths: z.array(z.string()).default([]),
|
||||||
enableSubmodules: z.boolean().optional(),
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ const GithubProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.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"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
|||||||
id: z.number().nullable(),
|
id: z.number().nullable(),
|
||||||
})
|
})
|
||||||
.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"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ const BitbucketProviderSchema = z.object({
|
|||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
.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"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ const GitProviderSchema = z.object({
|
|||||||
repositoryURL: z.string().min(1, {
|
repositoryURL: z.string().min(1, {
|
||||||
message: "Repository URL is required",
|
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(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ const GiteaProviderSchema = z.object({
|
|||||||
owner: z.string().min(1, "Owner is required"),
|
owner: z.string().min(1, "Owner is required"),
|
||||||
})
|
})
|
||||||
.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"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ const GitlabProviderSchema = z.object({
|
|||||||
gitlabPathNamespace: z.string().min(1),
|
gitlabPathNamespace: z.string().min(1),
|
||||||
})
|
})
|
||||||
.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"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
enableSubmodules: z.boolean().default(false),
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
||||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.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 ",
|
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||||
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||||
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
||||||
|
|||||||
97
apps/dokploy/scripts/migrate-auth-secret.ts
Normal file
97
apps/dokploy/scripts/migrate-auth-secret.ts
Normal file
@@ -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=<old_secret> NEW_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=<old> NEW_SECRET=<new> 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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -72,7 +72,7 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
sshClient
|
sshClient
|
||||||
.on("ready", () => {
|
.on("ready", () => {
|
||||||
const encodedPath = encodeBase64(logPath);
|
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) => {
|
sshClient!.exec(command, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|||||||
@@ -83,11 +83,6 @@ const getDockerConfig = (): Docker => {
|
|||||||
|
|
||||||
export const docker = getDockerConfig();
|
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) => {
|
export const paths = (isServer = false) => {
|
||||||
const BASE_PATH =
|
const BASE_PATH =
|
||||||
isServer || process.env.NODE_ENV === "production"
|
isServer || process.env.NODE_ENV === "production"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export const {
|
|||||||
POSTGRES_PORT = "5432",
|
POSTGRES_PORT = "5432",
|
||||||
} = process.env;
|
} = process.env;
|
||||||
|
|
||||||
function readSecret(path: string): string {
|
export function readSecret(path: string): string {
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(path, "utf8").trim();
|
return fs.readFileSync(path, "utf8").trim();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
28
packages/server/src/lib/auth-secret.ts
Normal file
28
packages/server/src/lib/auth-secret.ts
Normal file
@@ -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();
|
||||||
@@ -7,7 +7,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|||||||
import { APIError } from "better-auth/api";
|
import { APIError } from "better-auth/api";
|
||||||
import { admin, organization, twoFactor } from "better-auth/plugins";
|
import { admin, organization, twoFactor } from "better-auth/plugins";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
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 { db } from "../db";
|
||||||
import * as schema from "../db/schema";
|
import * as schema from "../db/schema";
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "../verification/send-verification-email";
|
} from "../verification/send-verification-email";
|
||||||
import { getPublicIpWithFallback } from "../wss/utils";
|
import { getPublicIpWithFallback } from "../wss/utils";
|
||||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||||
|
import { betterAuthSecret } from "./auth-secret";
|
||||||
|
|
||||||
const { handler, api } = betterAuth({
|
const { handler, api } = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
@@ -38,8 +39,9 @@ const { handler, api } = betterAuth({
|
|||||||
"/organization/create",
|
"/organization/create",
|
||||||
"/organization/update",
|
"/organization/update",
|
||||||
"/organization/delete",
|
"/organization/delete",
|
||||||
|
...(!IS_CLOUD ? ["/verify-email"] : []),
|
||||||
],
|
],
|
||||||
secret: BETTER_AUTH_SECRET,
|
secret: betterAuthSecret,
|
||||||
...(!IS_CLOUD
|
...(!IS_CLOUD
|
||||||
? {
|
? {
|
||||||
advanced: {
|
advanced: {
|
||||||
|
|||||||
@@ -670,7 +670,9 @@ export const uploadFileToContainer = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!destinationPathRegex.test(destinationPath)) {
|
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("/")
|
const normalizedPath = destinationPath.startsWith("/")
|
||||||
|
|||||||
Reference in New Issue
Block a user