From 2ca5321fdc2fe360efff1d5de534447f9bb11e4a Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 13 Jul 2025 23:12:09 -0600
Subject: [PATCH 1/6] feat(preview): add collaborator permission requirement
for preview deployments
- Introduced a new boolean field `previewRequireCollaboratorPermissions` in the application schema to enforce permission checks for preview deployments.
- Updated the UI to include a toggle for this setting in the preview deployment settings.
- Enhanced GitHub deployment handler to validate PR authors against the required permissions, blocking unauthorized deployments and providing security notifications.
- Added SQL migration to update the database schema accordingly.
---
.../show-preview-settings.tsx | 32 +
.../drizzle/0103_cultured_pestilence.sql | 1 +
apps/dokploy/drizzle/meta/0103_snapshot.json | 6136 +++++++++++++++++
apps/dokploy/drizzle/meta/_journal.json | 7 +
apps/dokploy/pages/api/deploy/github.ts | 78 +-
packages/server/src/db/schema/application.ts | 5 +
packages/server/src/index.ts | 1 +
packages/server/src/services/github.ts | 153 +
packages/server/src/utils/providers/github.ts | 43 +
9 files changed, 6454 insertions(+), 2 deletions(-)
create mode 100644 apps/dokploy/drizzle/0103_cultured_pestilence.sql
create mode 100644 apps/dokploy/drizzle/meta/0103_snapshot.json
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
index ae93ebcc4..283e3de86 100644
--- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
+++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
@@ -46,6 +46,7 @@ const schema = z
previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]),
previewCustomCertResolver: z.string().optional(),
+ previewRequireCollaboratorPermissions: z.boolean(),
})
.superRefine((input, ctx) => {
if (
@@ -83,6 +84,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: false,
previewPath: "/",
previewCertificateType: "none",
+ previewRequireCollaboratorPermissions: true,
},
resolver: zodResolver(schema),
});
@@ -105,6 +107,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
+ previewRequireCollaboratorPermissions:
+ data.previewRequireCollaboratorPermissions || true,
});
}
}, [data]);
@@ -121,6 +125,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver,
+ previewRequireCollaboratorPermissions:
+ formData.previewRequireCollaboratorPermissions,
})
.then(() => {
toast.success("Preview Deployments settings updated");
@@ -312,6 +318,32 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
+
+
(
+
+
+
+ Require Collaborator Permissions
+
+
+ Require collaborator permissions to preview
+ deployments.
+
+
+
+
+
+
+ )}
+ />
+
+
0) {
+ await createSecurityBlockedComment({
+ owner,
+ repository,
+ prNumber: Number.parseInt(prNumber),
+ prAuthor,
+ permission: userPermission,
+ githubId: githubResult.githubId,
+ });
+ }
+
+ for (const app of secureApps) {
const previewLimit = app?.previewLimit || 0;
- if (app?.previewDeployments?.length > previewLimit) {
+ if (app?.previewDeployments?.length >= previewLimit) {
continue;
}
const previewDeploymentResult =
diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts
index 05d96e8a8..40793fc88 100644
--- a/packages/server/src/db/schema/application.ts
+++ b/packages/server/src/db/schema/application.ts
@@ -131,6 +131,10 @@ export const applications = pgTable("application", {
isPreviewDeploymentsActive: boolean("isPreviewDeploymentsActive").default(
false,
),
+ // Security: Require collaborator permissions for preview deployments
+ previewRequireCollaboratorPermissions: boolean(
+ "previewRequireCollaboratorPermissions",
+ ).default(true),
rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"),
memoryReservation: text("memoryReservation"),
@@ -428,6 +432,7 @@ const createSchema = createInsertSchema(applications, {
previewHttps: z.boolean().optional(),
previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
+ previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(),
});
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 1c0655f8b..fca371ede 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -45,6 +45,7 @@ export * from "./setup/traefik-setup";
export * from "./setup/server-validate";
export * from "./setup/server-audit";
export * from "./utils/watch-paths/should-deploy";
+export * from "./utils/providers/github";
export * from "./utils/backups/index";
export * from "./utils/backups/mariadb";
export * from "./utils/backups/mongo";
diff --git a/packages/server/src/services/github.ts b/packages/server/src/services/github.ts
index 1520a00a4..903fbf592 100644
--- a/packages/server/src/services/github.ts
+++ b/packages/server/src/services/github.ts
@@ -192,3 +192,156 @@ export const createPreviewDeploymentComment = async ({
pullRequestCommentId: `${issue.data.id}`,
}).then((response) => response[0]);
};
+
+/**
+ * Generate security notification message for blocked PR deployments
+ */
+export const getSecurityBlockedMessage = (
+ prAuthor: string,
+ repositoryName: string,
+ permission: string | null,
+) => {
+ return `### đ¨ Preview Deployment Blocked - Security Protection
+
+**Your pull request was blocked from triggering preview deployments**
+
+#### Why was this blocked?
+- **User**: \`${prAuthor}\`
+- **Repository**: \`${repositoryName}\`
+- **Permission Level**: \`${permission || "none"}\`
+- **Required Level**: \`write\`, \`maintain\`, or \`admin\`
+
+#### How to resolve this:
+
+**Option 1: Get Collaborator Access (Recommended)**
+Ask a repository maintainer to invite you as a collaborator with **write permissions** or higher.
+
+**Option 2: Request Permission Override**
+Ask a repository administrator to disable security validation for this specific application if appropriate.
+
+#### For Repository Administrators:
+To disable this security check (â ī¸ **not recommended for public repositories**):
+Enter to preview settings and disable the security check.
+
+---
+*This security measure protects against malicious code execution in preview deployments. Only trusted collaborators should have the ability to trigger deployments.*
+
+
+đĄī¸ Learn more about this security feature
+
+This protection prevents unauthorized users from:
+- Executing malicious code on the deployment server
+- Accessing environment variables and secrets
+- Potentially compromising the infrastructure
+
+Preview deployments are powerful but require trust. Only users with repository write access can trigger them.
+ `;
+};
+
+/**
+ * Check if a security notification comment already exists on a GitHub PR
+ * This prevents creating duplicate security comments on subsequent pushes
+ */
+export const hasExistingSecurityComment = async ({
+ owner,
+ repository,
+ prNumber,
+ githubId,
+}: {
+ owner: string;
+ repository: string;
+ prNumber: number;
+ githubId: string;
+}): Promise => {
+ try {
+ const github = await findGithubById(githubId);
+ const octokit = authGithub(github);
+
+ // Get all comments for this PR
+ const { data: comments } = await octokit.rest.issues.listComments({
+ owner,
+ repo: repository,
+ issue_number: prNumber,
+ });
+
+ // Check if any comment contains our security notification marker
+ const securityCommentExists = comments.some((comment) =>
+ comment.body?.includes(
+ "đ¨ Preview Deployment Blocked - Security Protection",
+ ),
+ );
+
+ return securityCommentExists;
+ } catch (error) {
+ console.error(
+ `â Failed to check existing comments on PR #${prNumber}:`,
+ error,
+ );
+ // If we can't check, assume no comment exists to avoid blocking functionality
+ return false;
+ }
+};
+
+/**
+ * Create a security notification comment on a GitHub PR
+ */
+export const createSecurityBlockedComment = async ({
+ owner,
+ repository,
+ prNumber,
+ prAuthor,
+ permission,
+ githubId,
+}: {
+ owner: string;
+ repository: string;
+ prNumber: number;
+ prAuthor: string;
+ permission: string | null;
+ githubId: string;
+}) => {
+ try {
+ // Check if a security comment already exists to prevent duplicates
+ const commentExists = await hasExistingSecurityComment({
+ owner,
+ repository,
+ prNumber,
+ githubId,
+ });
+
+ if (commentExists) {
+ console.log(
+ `âšī¸ Security notification comment already exists on PR #${prNumber}, skipping duplicate`,
+ );
+ return null;
+ }
+
+ const github = await findGithubById(githubId);
+ const octokit = authGithub(github);
+
+ const securityMessage = getSecurityBlockedMessage(
+ prAuthor,
+ repository,
+ permission,
+ );
+
+ const issue = await octokit.rest.issues.createComment({
+ owner,
+ repo: repository,
+ issue_number: prNumber,
+ body: securityMessage,
+ });
+
+ console.log(
+ `â
Security notification comment created on PR #${prNumber}: ${issue.data.html_url}`,
+ );
+ return issue.data;
+ } catch (error) {
+ console.error(
+ `â Failed to create security comment on PR #${prNumber}:`,
+ error,
+ );
+ // Don't throw error - security comment is nice-to-have, not critical
+ return null;
+ }
+};
diff --git a/packages/server/src/utils/providers/github.ts b/packages/server/src/utils/providers/github.ts
index d2355a663..c229f838f 100644
--- a/packages/server/src/utils/providers/github.ts
+++ b/packages/server/src/utils/providers/github.ts
@@ -45,6 +45,49 @@ export const getGithubToken = async (
return installation.token;
};
+/**
+ * Check if a GitHub user has write/admin permissions on a repository
+ * This is used to validate PR authors before allowing preview deployments
+ */
+export const checkUserRepositoryPermissions = async (
+ githubProvider: Github,
+ owner: string,
+ repo: string,
+ username: string,
+): Promise<{ hasWriteAccess: boolean; permission: string | null }> => {
+ try {
+ const octokit = authGithub(githubProvider);
+
+ // Check if user is a collaborator with write permissions
+ const { data: permission } =
+ await octokit.rest.repos.getCollaboratorPermissionLevel({
+ owner,
+ repo,
+ username,
+ });
+
+ // Allow only users with 'write', 'admin', or 'maintain' permissions
+ // Currently exists Read, Triage, Write, Maintain, Admin
+ const allowedPermissions = ["write", "admin", "maintain"];
+ const hasWriteAccess = allowedPermissions.includes(permission.permission);
+
+ return {
+ hasWriteAccess,
+ permission: permission.permission,
+ };
+ } catch (error) {
+ // If user is not a collaborator, GitHub API returns 404
+ console.warn(
+ `User ${username} is not a collaborator of ${owner}/${repo}:`,
+ error,
+ );
+ return {
+ hasWriteAccess: false,
+ permission: null,
+ };
+ }
+};
+
export const haveGithubRequirements = (githubProvider: Github) => {
return !!(
githubProvider?.githubAppId &&
From 666db23b8e2dc38a0af43b052c17dd0e8e662486 Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 13 Jul 2025 23:17:32 -0600
Subject: [PATCH 2/6] test: add previewRequireCollaboratorPermissions field to
drop and traefik test cases
---
apps/dokploy/__test__/drop/drop.test.test.ts | 1 +
apps/dokploy/__test__/traefik/traefik.test.ts | 1 +
2 files changed, 2 insertions(+)
diff --git a/apps/dokploy/__test__/drop/drop.test.test.ts b/apps/dokploy/__test__/drop/drop.test.test.ts
index 9fa68b6bb..8fda40e51 100644
--- a/apps/dokploy/__test__/drop/drop.test.test.ts
+++ b/apps/dokploy/__test__/drop/drop.test.test.ts
@@ -29,6 +29,7 @@ const baseApp: ApplicationNested = {
herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",
+ previewRequireCollaboratorPermissions: false,
giteaId: "",
giteaOwner: "",
giteaRepository: "",
diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts
index f2d0f0a50..c1517d530 100644
--- a/apps/dokploy/__test__/traefik/traefik.test.ts
+++ b/apps/dokploy/__test__/traefik/traefik.test.ts
@@ -18,6 +18,7 @@ const baseApp: ApplicationNested = {
appName: "",
autoDeploy: true,
enableSubmodules: false,
+ previewRequireCollaboratorPermissions: false,
serverId: "",
branch: null,
dockerBuildStage: "",
From 18b65f28f2874dcd3813700fc8057713812a1b9f Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 13 Jul 2025 23:19:31 -0600
Subject: [PATCH 3/6] chore(package): bump version to v0.24.3 and comment out
unused trustedOrigins function in auth.ts
---
apps/dokploy/package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json
index b37d4c7f8..c6d2a8e2c 100644
--- a/apps/dokploy/package.json
+++ b/apps/dokploy/package.json
@@ -1,6 +1,6 @@
{
"name": "dokploy",
- "version": "v0.24.2",
+ "version": "v0.24.3",
"private": true,
"license": "Apache-2.0",
"type": "module",
From 1dd713a1d1411bf697e98d22db0c784b9e278cb7 Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 13 Jul 2025 23:20:23 -0600
Subject: [PATCH 4/6] fix(deploy): change preview deployment limit check to be
exclusive
---
apps/dokploy/pages/api/deploy/github.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/dokploy/pages/api/deploy/github.ts b/apps/dokploy/pages/api/deploy/github.ts
index 5f518b696..325b4f1be 100644
--- a/apps/dokploy/pages/api/deploy/github.ts
+++ b/apps/dokploy/pages/api/deploy/github.ts
@@ -443,7 +443,7 @@ export default async function handler(
for (const app of secureApps) {
const previewLimit = app?.previewLimit || 0;
- if (app?.previewDeployments?.length >= previewLimit) {
+ if (app?.previewDeployments?.length > previewLimit) {
continue;
}
const previewDeploymentResult =
From 93d666246616a36e9f1c5ea3c702d720128f39f3 Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 13 Jul 2025 23:26:41 -0600
Subject: [PATCH 5/6] docs(preview): update collaborator permission description
in preview settings
---
.../preview-deployments/show-preview-settings.tsx | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
index 283e3de86..a0f6ae0e4 100644
--- a/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
+++ b/apps/dokploy/components/dashboard/application/preview-deployments/show-preview-settings.tsx
@@ -330,7 +330,12 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
Require collaborator permissions to preview
- deployments.
+ deployments, valid roles are:
+
+ - Admin
+ - Maintain
+ - Write
+
From fdc524d79d0f65d9f99fe7751581473fe80bfcbc Mon Sep 17 00:00:00 2001
From: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Date: Sun, 13 Jul 2025 23:37:05 -0600
Subject: [PATCH 6/6] fix(ui): adjust layout in UpdateServer component
- Removed unnecessary padding from DialogContent for a cleaner appearance.
- Added margin-top to the button container for improved spacing.
---
.../dashboard/settings/web-server/update-server.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx
index 1b7eb91a2..159e42775 100644
--- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx
+++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx
@@ -126,7 +126,7 @@ export const UpdateServer = ({
)}
-
+
Web Server Update
@@ -253,7 +253,7 @@ export const UpdateServer = ({
-
+