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 = ({
-
+