From 5ae344db5814bfdcd700c5cc8ee8eb1a23af7253 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 15:32:06 -0600 Subject: [PATCH 1/2] fix(security): authenticate and authorize the GitHub App setup callback The /api/providers/github/setup callback performed privileged writes (persisting App client_secret/webhook_secret/private_key, re-pointing installations) with no session check, deriving the write target (organizationId/userId on gh_init, githubId on gh_setup) from the attacker-controlled state parameter. An unauthenticated request, or a member with no gitProviders permission, could plant or re-point a provider in any organization. Require a valid session, gate on the gitProviders permission (same guard the tRPC github router uses), derive the create target from the session, and verify the gh_setup provider belongs to the caller's organization. --- .../pages/api/providers/github/setup.ts | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/apps/dokploy/pages/api/providers/github/setup.ts b/apps/dokploy/pages/api/providers/github/setup.ts index 663939c5e..b6be941b6 100644 --- a/apps/dokploy/pages/api/providers/github/setup.ts +++ b/apps/dokploy/pages/api/providers/github/setup.ts @@ -1,5 +1,6 @@ -import { createGithub } from "@dokploy/server"; +import { createGithub, validateRequest } from "@dokploy/server"; import { db } from "@dokploy/server/db"; +import { hasPermission } from "@dokploy/server/services/permission"; import { eq } from "drizzle-orm"; import type { NextApiRequest, NextApiResponse } from "next"; import { Octokit } from "octokit"; @@ -21,18 +22,27 @@ export default async function handler( if (!code) { return res.status(400).json({ error: "Missing code parameter" }); } - const [action, ...rest] = state?.split(":"); - // For gh_init: rest[0] = organizationId, rest[1] = userId - // For gh_setup: rest[0] = githubProviderId + + // This callback performs privileged writes (persisting App secrets, re-pointing + // installations) but sits outside tRPC, so it must authenticate and authorize + // on its own. The write target is derived from the session, never from `state`. + const { user, session } = await validateRequest(req); + if (!user || !session?.activeOrganizationId) { + return res.status(401).json({ error: "Unauthorized" }); + } + const ctx = { + user: { id: user.id }, + session: { activeOrganizationId: session.activeOrganizationId }, + }; + + const [action] = state?.split(":") ?? []; + // gh_init creates a provider, gh_setup re-points an existing one; both require + // the gitProviders permission (the same guard the tRPC github router uses). + if (!(await hasPermission(ctx, { gitProviders: ["create"] }))) { + return res.status(403).json({ error: "Forbidden" }); + } if (action === "gh_init") { - const organizationId = rest[0]; - const userId = rest[1] || (req.query.userId as string); - - if (!userId) { - return res.status(400).json({ error: "Missing userId parameter" }); - } - const octokit = new Octokit({}); const { data } = await octokit.request( "POST /app-manifests/{code}/conversions", @@ -51,16 +61,32 @@ export default async function handler( githubWebhookSecret: data.webhook_secret, githubPrivateKey: data.pem, }, - organizationId as string, - userId, + session.activeOrganizationId, + user.id, ); } else if (action === "gh_setup") { + const githubId = state?.split(":")[1]; + if (!githubId) { + return res.status(400).json({ error: "Missing github provider id" }); + } + + const provider = await db.query.github.findFirst({ + where: eq(github.githubId, githubId), + with: { gitProvider: true }, + }); + if ( + !provider || + provider.gitProvider.organizationId !== session.activeOrganizationId + ) { + return res.status(404).json({ error: "Github provider not found" }); + } + await db .update(github) .set({ githubInstallationId: installation_id, }) - .where(eq(github.githubId, rest[0] as string)) + .where(eq(github.githubId, githubId)) .returning(); } From 0514363062fca15e6940e5a2c8c65cd502f02810 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 20 Jul 2026 15:37:17 -0600 Subject: [PATCH 2/2] chore: drop explanatory comments in github setup handler --- apps/dokploy/pages/api/providers/github/setup.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/dokploy/pages/api/providers/github/setup.ts b/apps/dokploy/pages/api/providers/github/setup.ts index b6be941b6..fc8869938 100644 --- a/apps/dokploy/pages/api/providers/github/setup.ts +++ b/apps/dokploy/pages/api/providers/github/setup.ts @@ -23,9 +23,6 @@ export default async function handler( return res.status(400).json({ error: "Missing code parameter" }); } - // This callback performs privileged writes (persisting App secrets, re-pointing - // installations) but sits outside tRPC, so it must authenticate and authorize - // on its own. The write target is derived from the session, never from `state`. const { user, session } = await validateRequest(req); if (!user || !session?.activeOrganizationId) { return res.status(401).json({ error: "Unauthorized" }); @@ -36,8 +33,6 @@ export default async function handler( }; const [action] = state?.split(":") ?? []; - // gh_init creates a provider, gh_setup re-points an existing one; both require - // the gitProviders permission (the same guard the tRPC github router uses). if (!(await hasPermission(ctx, { gitProviders: ["create"] }))) { return res.status(403).json({ error: "Forbidden" }); }