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