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.
This commit is contained in:
Mauricio Siu
2026-07-20 15:32:06 -06:00
parent 393fb92344
commit 5ae344db58

View File

@@ -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();
}