Skip to content

Commit cf2f177

Browse files
fix: address PR review issues in GitLab webhook handler
- Guard `hasExistingSecurityMRNote` against non-ok HTTP responses before parsing JSON (was a silent failure path) - Separate "Tag Push Hook" from "Push Hook" into distinct handlers; tag deploys now parse `refs/tags/`, filter by `triggerType = "tag"`, and omit the branch constraint — matching the GitHub handler pattern - Add `triggerType = "push"` filter to push-hook app and compose queries so tag-triggered repos are not accidentally deployed on push events - Remove `unlabeled` from the MR action allowlist; the event previously triggered security checks and the full app loop while doing nothing (shouldCreateDeployment already excluded it) - Hoist `checkGitlabMemberPermissions` before the per-app loop — the check is per-MR-author so N apps no longer make N API calls - Drop explicit `{ gitProvider: any }` return type on `findGitlabByWebhookSecret`; TypeScript now infers the Drizzle type - Use `encode(gen_random_bytes(21), 'base64')` in migration instead of `gen_random_uuid()::text` for consistency with the rest of the schema Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d806ff5 commit cf2f177

4 files changed

Lines changed: 134 additions & 36 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
ALTER TABLE "gitlab" ADD COLUMN "webhook_secret" text;
22
--> statement-breakpoint
3-
UPDATE "gitlab" SET "webhook_secret" = gen_random_uuid()::text WHERE "webhook_secret" IS NULL;
3+
UPDATE "gitlab" SET "webhook_secret" = encode(gen_random_bytes(21), 'base64') WHERE "webhook_secret" IS NULL;
44
--> statement-breakpoint
55
ALTER TABLE "gitlab" ALTER COLUMN "webhook_secret" SET NOT NULL;

apps/dokploy/pages/api/deploy/gitlab.ts

Lines changed: 129 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,92 @@ export default async function handler(
3838
const event = req.headers["x-gitlab-event"] as string;
3939
const body = req.body;
4040

41-
if (event === "Push Hook" || event === "Tag Push Hook") {
41+
if (event === "Tag Push Hook") {
42+
try {
43+
const tagName = body?.ref?.replace("refs/tags/", "");
44+
const deploymentHash = body?.checkout_sha;
45+
const pathNamespace = body?.project?.path_with_namespace;
46+
47+
const apps = await db.query.applications.findMany({
48+
where: and(
49+
eq(applications.sourceType, "gitlab"),
50+
eq(applications.autoDeploy, true),
51+
eq(applications.triggerType, "tag"),
52+
eq(applications.gitlabPathNamespace, pathNamespace),
53+
eq(applications.gitlabId, gitlabProvider.gitlabId),
54+
),
55+
});
56+
57+
for (const app of apps) {
58+
const jobData: DeploymentJob = {
59+
applicationId: app.applicationId as string,
60+
titleLog: `Tag created: ${tagName}`,
61+
descriptionLog: `Hash: ${deploymentHash}`,
62+
type: "deploy",
63+
applicationType: "application",
64+
server: !!app.serverId,
65+
};
66+
67+
if (IS_CLOUD && app.serverId) {
68+
jobData.serverId = app.serverId;
69+
deploy(jobData).catch((error) => {
70+
console.error("Background deployment failed:", error);
71+
});
72+
continue;
73+
}
74+
await myQueue.add("deployments", { ...jobData }, {
75+
removeOnComplete: true,
76+
removeOnFail: true,
77+
});
78+
}
79+
80+
const composeApps = await db.query.compose.findMany({
81+
where: and(
82+
eq(compose.sourceType, "gitlab"),
83+
eq(compose.autoDeploy, true),
84+
eq(compose.triggerType, "tag"),
85+
eq(compose.gitlabPathNamespace, pathNamespace),
86+
eq(compose.gitlabId, gitlabProvider.gitlabId),
87+
),
88+
});
89+
90+
for (const composeApp of composeApps) {
91+
const jobData: DeploymentJob = {
92+
composeId: composeApp.composeId as string,
93+
titleLog: `Tag created: ${tagName}`,
94+
descriptionLog: `Hash: ${deploymentHash}`,
95+
type: "deploy",
96+
applicationType: "compose",
97+
server: !!composeApp.serverId,
98+
};
99+
100+
if (IS_CLOUD && composeApp.serverId) {
101+
jobData.serverId = composeApp.serverId;
102+
deploy(jobData).catch((error) => {
103+
console.error("Background deployment failed:", error);
104+
});
105+
continue;
106+
}
107+
await myQueue.add("deployments", { ...jobData }, {
108+
removeOnComplete: true,
109+
removeOnFail: true,
110+
});
111+
}
112+
113+
const totalApps = apps.length + composeApps.length;
114+
res.status(200).json({
115+
message:
116+
totalApps === 0
117+
? "No apps configured to deploy on tag"
118+
: `Deployed ${totalApps} apps based on tag ${tagName}`,
119+
});
120+
} catch (error) {
121+
res.status(400).json({ message: "Error deploying application", error });
122+
}
123+
return;
124+
}
125+
126+
if (event === "Push Hook") {
42127
try {
43128
const branchName = body?.ref?.replace("refs/heads/", "");
44129
const deploymentHash = body?.checkout_sha;
@@ -55,6 +140,7 @@ export default async function handler(
55140
where: and(
56141
eq(applications.sourceType, "gitlab"),
57142
eq(applications.autoDeploy, true),
143+
eq(applications.triggerType, "push"),
58144
eq(applications.gitlabPathNamespace, pathNamespace),
59145
eq(applications.gitlabBranch, branchName),
60146
eq(applications.gitlabId, gitlabProvider.gitlabId),
@@ -92,6 +178,7 @@ export default async function handler(
92178
where: and(
93179
eq(compose.sourceType, "gitlab"),
94180
eq(compose.autoDeploy, true),
181+
eq(compose.triggerType, "push"),
95182
eq(compose.gitlabPathNamespace, pathNamespace),
96183
eq(compose.gitlabBranch, branchName),
97184
eq(compose.gitlabId, gitlabProvider.gitlabId),
@@ -126,7 +213,12 @@ export default async function handler(
126213
}
127214

128215
const totalApps = apps.length + composeApps.length;
129-
res.status(200).json({ message: totalApps === 0 ? "No apps to deploy" : `Deployed ${totalApps} apps` });
216+
res.status(200).json({
217+
message:
218+
totalApps === 0
219+
? "No apps to deploy"
220+
: `Deployed ${totalApps} apps`,
221+
});
130222
} catch (error) {
131223
res.status(400).json({ message: "Error deploying application", error });
132224
}
@@ -173,15 +265,8 @@ export default async function handler(
173265
action === "open" ||
174266
action === "update" ||
175267
action === "reopen" ||
176-
action === "labeled" ||
177-
action === "unlabeled"
268+
action === "labeled"
178269
) {
179-
const shouldCreateDeployment =
180-
action === "open" ||
181-
action === "update" ||
182-
action === "reopen" ||
183-
action === "labeled";
184-
185270
const targetBranch = body?.object_attributes?.target_branch as string;
186271
const sourceBranch = body?.object_attributes?.source_branch as string;
187272
const mrTitle = body?.object_attributes?.title as string;
@@ -202,36 +287,46 @@ export default async function handler(
202287
},
203288
});
204289

205-
// Security: check member permissions per app
290+
// Permission check is per-MR-author, not per-app — check once before the loop
291+
const requiresPermissionCheck = apps.some(
292+
(app) => app.previewRequireCollaboratorPermissions !== false,
293+
);
294+
let permissionResult: Awaited<
295+
ReturnType<typeof checkGitlabMemberPermissions>
296+
> | null = null;
297+
let permissionError: unknown = null;
298+
299+
if (requiresPermissionCheck) {
300+
try {
301+
permissionResult = await checkGitlabMemberPermissions(
302+
gitlabProvider.gitlabId,
303+
projectId,
304+
mrAuthor,
305+
);
306+
} catch (error) {
307+
permissionError = error;
308+
console.error("Error validating MR author permissions:", error);
309+
}
310+
}
311+
206312
const secureApps: typeof apps = [];
207313
let blockedAccessLevel: number | null = null;
208314
let blocked = false;
209315

210316
for (const app of apps) {
211317
if (app.previewRequireCollaboratorPermissions !== false) {
212-
try {
213-
const { hasWriteAccess, accessLevel } =
214-
await checkGitlabMemberPermissions(
215-
gitlabProvider.gitlabId,
216-
projectId,
217-
mrAuthor,
218-
);
219-
220-
if (!hasWriteAccess) {
221-
console.warn(
222-
`🚨 SECURITY: Blocked preview deployment for ${app.name} from ${mrAuthor}. Access level: ${accessLevel}`,
223-
);
224-
if (!blocked) {
225-
blockedAccessLevel = accessLevel;
226-
blocked = true;
227-
}
228-
continue;
229-
}
230-
} catch (error) {
231-
console.error(
232-
`Error validating MR author permissions for ${app.name}:`,
233-
error,
318+
if (permissionError) {
319+
continue;
320+
}
321+
const { hasWriteAccess, accessLevel } = permissionResult!;
322+
if (!hasWriteAccess) {
323+
console.warn(
324+
`🚨 SECURITY: Blocked preview deployment for ${app.name} from ${mrAuthor}. Access level: ${accessLevel}`,
234325
);
326+
if (!blocked) {
327+
blockedAccessLevel = accessLevel;
328+
blocked = true;
329+
}
235330
continue;
236331
}
237332
}
@@ -275,7 +370,7 @@ export default async function handler(
275370
let previewDeploymentId =
276371
existingDeployment?.previewDeploymentId ?? "";
277372

278-
if (!existingDeployment && shouldCreateDeployment) {
373+
if (!existingDeployment) {
279374
const newDeployment = await createPreviewDeployment({
280375
applicationId: app.applicationId as string,
281376
branch: sourceBranch,

packages/server/src/services/gitlab.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const findGitlabById = async (gitlabId: string) => {
6565

6666
export const findGitlabByWebhookSecret = async (
6767
webhookSecret: string,
68-
): Promise<(typeof gitlab.$inferSelect & { gitProvider: any }) | null> => {
68+
) => {
6969
const result = await db.query.gitlab.findFirst({
7070
where: eq(gitlab.webhookSecret, webhookSecret),
7171
with: {

packages/server/src/utils/providers/gitlab.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ export const hasExistingSecurityMRNote = async (
350350
`${baseUrl}/api/v4/projects/${projectId}/merge_requests/${mrIid}/notes`,
351351
{ headers: { Authorization: `Bearer ${gitlabProvider.accessToken}` } },
352352
);
353+
if (!response.ok) {
354+
return false;
355+
}
353356
const notes: { id: number; body: string }[] = await response.json();
354357
return notes.some((note) => note.body.includes(SECURITY_SENTINEL));
355358
};

0 commit comments

Comments
 (0)