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] 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 &&