From 071d9eacee9744f13193e92757a7fcd3bab0956a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 20:55:53 -0600 Subject: [PATCH 1/2] fix(security): enforce org-scope on git provider read endpoints The .one / getRepositories / getBranches / testConnection handlers for github, gitlab, gitea and bitbucket resolved a provider by bare id under protectedProcedure and returned the full row (OAuth tokens, app private keys, webhook secrets) with no organization or entitlement check, letting any authenticated user read another org's git-provider credentials (IDOR). Adds assertGitProviderAccess() in the git-provider service (rejects cross-org with NOT_FOUND and non-entitled same-org with FORBIDDEN, reusing the existing getAccessibleGitProviderIds entitlement logic) and calls it in every read handler before returning secret-bearing data. Closes GHSA-66r5-5m5f-57vg --- .../git-provider/git-provider-idor.test.ts | 91 +++++++++++++++++++ apps/dokploy/server/api/routers/bitbucket.ts | 19 +++- apps/dokploy/server/api/routers/gitea.ts | 26 ++++-- apps/dokploy/server/api/routers/github.ts | 23 +++-- apps/dokploy/server/api/routers/gitlab.ts | 23 +++-- packages/server/src/services/git-provider.ts | 27 ++++++ 6 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 apps/dokploy/__test__/git-provider/git-provider-idor.test.ts diff --git a/apps/dokploy/__test__/git-provider/git-provider-idor.test.ts b/apps/dokploy/__test__/git-provider/git-provider-idor.test.ts new file mode 100644 index 000000000..6c1e39771 --- /dev/null +++ b/apps/dokploy/__test__/git-provider/git-provider-idor.test.ts @@ -0,0 +1,91 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by +// assertGitProviderAccess) runs against controlled data. Mocking the exported +// function would NOT intercept the intra-module call, so we mock one layer down. +const mockDb = vi.hoisted(() => ({ + query: { + gitProvider: { + findMany: vi.fn(), + }, + member: { + findFirst: vi.fn(), + }, + }, +})); +vi.mock("@dokploy/server/db", () => ({ db: mockDb })); + +const mockHasValidLicense = vi.hoisted(() => vi.fn()); +vi.mock("@dokploy/server/services/proprietary/license-key", () => ({ + hasValidLicense: mockHasValidLicense, +})); + +import { assertGitProviderAccess } from "@dokploy/server/services/git-provider"; + +const ORG = "org-1"; +const USER = "user-member"; +const session = { userId: USER, activeOrganizationId: ORG }; + +// Provider owned by USER within ORG -> should be accessible. +const providerMine = { + gitProviderId: "gp-mine", + userId: USER, + sharedWithOrganization: false, +}; +// Provider owned by someone else within ORG, not shared, not assigned. +const providerOther = { + gitProviderId: "gp-other", + userId: "user-2", + sharedWithOrganization: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockHasValidLicense.mockResolvedValue(false); + mockDb.query.gitProvider.findMany.mockResolvedValue([ + providerMine, + providerOther, + ]); + mockDb.query.member.findFirst.mockResolvedValue({ + role: "member", + accessedGitProviders: [], + }); +}); + +describe("assertGitProviderAccess (git provider IDOR guard)", () => { + it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => { + await expect( + assertGitProviderAccess(session, { + gitProviderId: "gp-mine", + organizationId: "org-2", + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => { + await expect( + assertGitProviderAccess(session, { + gitProviderId: "gp-other", + organizationId: ORG, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("allows a same-org provider the caller owns", async () => { + await expect( + assertGitProviderAccess(session, { + gitProviderId: "gp-mine", + organizationId: ORG, + }), + ).resolves.toBeUndefined(); + }); + + it("throws a TRPCError so tRPC maps the HTTP status", async () => { + const err = await assertGitProviderAccess(session, { + gitProviderId: "gp-mine", + organizationId: "org-2", + }).catch((e) => e); + expect(err).toBeInstanceOf(TRPCError); + }); +}); diff --git a/apps/dokploy/server/api/routers/bitbucket.ts b/apps/dokploy/server/api/routers/bitbucket.ts index 7830b6b4c..c5a7e41a1 100644 --- a/apps/dokploy/server/api/routers/bitbucket.ts +++ b/apps/dokploy/server/api/routers/bitbucket.ts @@ -1,4 +1,5 @@ import { + assertGitProviderAccess, createBitbucket, findBitbucketById, getAccessibleGitProviderIds, @@ -51,8 +52,10 @@ export const bitbucketRouter = createTRPCRouter({ }), one: protectedProcedure .input(apiFindOneBitbucket) - .query(async ({ input }) => { - return await findBitbucketById(input.bitbucketId); + .query(async ({ input, ctx }) => { + const bitbucket = await findBitbucketById(input.bitbucketId); + await assertGitProviderAccess(ctx.session, bitbucket.gitProvider); + return bitbucket; }), bitbucketProviders: protectedProcedure.query(async ({ ctx }) => { const accessibleIds = await getAccessibleGitProviderIds(ctx.session); @@ -78,18 +81,24 @@ export const bitbucketRouter = createTRPCRouter({ getBitbucketRepositories: protectedProcedure .input(apiFindOneBitbucket) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const bitbucket = await findBitbucketById(input.bitbucketId); + await assertGitProviderAccess(ctx.session, bitbucket.gitProvider); return await getBitbucketRepositories(input.bitbucketId); }), getBitbucketBranches: protectedProcedure .input(apiFindBitbucketBranches) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const bitbucket = await findBitbucketById(input.bitbucketId); + await assertGitProviderAccess(ctx.session, bitbucket.gitProvider); return await getBitbucketBranches(input); }), testConnection: protectedProcedure .input(apiBitbucketTestConnection) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { + const bitbucket = await findBitbucketById(input.bitbucketId); + await assertGitProviderAccess(ctx.session, bitbucket.gitProvider); const result = await testBitbucketConnection(input); return `Found ${result} repositories`; diff --git a/apps/dokploy/server/api/routers/gitea.ts b/apps/dokploy/server/api/routers/gitea.ts index b16a351c1..0ec08964d 100644 --- a/apps/dokploy/server/api/routers/gitea.ts +++ b/apps/dokploy/server/api/routers/gitea.ts @@ -1,4 +1,5 @@ import { + assertGitProviderAccess, createGitea, findGiteaById, getAccessibleGitProviderIds, @@ -53,9 +54,13 @@ export const giteaRouter = createTRPCRouter({ } }), - one: protectedProcedure.input(apiFindOneGitea).query(async ({ input }) => { - return await findGiteaById(input.giteaId); - }), + one: protectedProcedure + .input(apiFindOneGitea) + .query(async ({ input, ctx }) => { + const gitea = await findGiteaById(input.giteaId); + await assertGitProviderAccess(ctx.session, gitea.gitProvider); + return gitea; + }), giteaProviders: protectedProcedure.query(async ({ ctx }) => { const accessibleIds = await getAccessibleGitProviderIds(ctx.session); @@ -89,7 +94,7 @@ export const giteaRouter = createTRPCRouter({ getGiteaRepositories: protectedProcedure .input(apiFindOneGitea) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const { giteaId } = input; if (!giteaId) { @@ -99,6 +104,9 @@ export const giteaRouter = createTRPCRouter({ }); } + const gitea = await findGiteaById(giteaId); + await assertGitProviderAccess(ctx.session, gitea.gitProvider); + try { const repositories = await getGiteaRepositories(giteaId); return repositories; @@ -113,7 +121,7 @@ export const giteaRouter = createTRPCRouter({ getGiteaBranches: protectedProcedure .input(apiFindGiteaBranches) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const { giteaId, owner, repositoryName } = input; if (!giteaId || !owner || !repositoryName) { @@ -124,6 +132,9 @@ export const giteaRouter = createTRPCRouter({ }); } + const gitea = await findGiteaById(giteaId); + await assertGitProviderAccess(ctx.session, gitea.gitProvider); + try { return await getGiteaBranches({ giteaId, @@ -141,9 +152,12 @@ export const giteaRouter = createTRPCRouter({ testConnection: protectedProcedure .input(apiGiteaTestConnection) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { const giteaId = input.giteaId ?? ""; + const gitea = await findGiteaById(giteaId); + await assertGitProviderAccess(ctx.session, gitea.gitProvider); + try { const result = await testGiteaConnection({ giteaId, diff --git a/apps/dokploy/server/api/routers/github.ts b/apps/dokploy/server/api/routers/github.ts index f337a05fc..d27388863 100644 --- a/apps/dokploy/server/api/routers/github.ts +++ b/apps/dokploy/server/api/routers/github.ts @@ -1,4 +1,5 @@ import { + assertGitProviderAccess, findGithubById, getAccessibleGitProviderIds, getGithubBranches, @@ -22,17 +23,25 @@ import { } from "@/server/db/schema"; export const githubRouter = createTRPCRouter({ - one: protectedProcedure.input(apiFindOneGithub).query(async ({ input }) => { - return await findGithubById(input.githubId); - }), + one: protectedProcedure + .input(apiFindOneGithub) + .query(async ({ input, ctx }) => { + const github = await findGithubById(input.githubId); + await assertGitProviderAccess(ctx.session, github.gitProvider); + return github; + }), getGithubRepositories: protectedProcedure .input(apiFindOneGithub) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const github = await findGithubById(input.githubId); + await assertGitProviderAccess(ctx.session, github.gitProvider); return await getGithubRepositories(input.githubId); }), getGithubBranches: protectedProcedure .input(apiFindGithubBranches) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const github = await findGithubById(input.githubId); + await assertGitProviderAccess(ctx.session, github.gitProvider); return await getGithubBranches(input); }), githubProviders: protectedProcedure.query(async ({ ctx }) => { @@ -67,8 +76,10 @@ export const githubRouter = createTRPCRouter({ testConnection: protectedProcedure .input(apiFindOneGithub) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { + const github = await findGithubById(input.githubId); + await assertGitProviderAccess(ctx.session, github.gitProvider); const result = await getGithubRepositories(input.githubId); return `Found ${result.length} repositories`; } catch (err) { diff --git a/apps/dokploy/server/api/routers/gitlab.ts b/apps/dokploy/server/api/routers/gitlab.ts index ff42a8bbe..76b4cd16d 100644 --- a/apps/dokploy/server/api/routers/gitlab.ts +++ b/apps/dokploy/server/api/routers/gitlab.ts @@ -1,4 +1,5 @@ import { + assertGitProviderAccess, createGitlab, findGitlabById, getAccessibleGitProviderIds, @@ -51,9 +52,13 @@ export const gitlabRouter = createTRPCRouter({ }); } }), - one: protectedProcedure.input(apiFindOneGitlab).query(async ({ input }) => { - return await findGitlabById(input.gitlabId); - }), + one: protectedProcedure + .input(apiFindOneGitlab) + .query(async ({ input, ctx }) => { + const gitlab = await findGitlabById(input.gitlabId); + await assertGitProviderAccess(ctx.session, gitlab.gitProvider); + return gitlab; + }), gitlabProviders: protectedProcedure.query(async ({ ctx }) => { const accessibleIds = await getAccessibleGitProviderIds(ctx.session); @@ -86,19 +91,25 @@ export const gitlabRouter = createTRPCRouter({ }), getGitlabRepositories: protectedProcedure .input(apiFindOneGitlab) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const gitlab = await findGitlabById(input.gitlabId); + await assertGitProviderAccess(ctx.session, gitlab.gitProvider); return await getGitlabRepositories(input.gitlabId); }), getGitlabBranches: protectedProcedure .input(apiFindGitlabBranches) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { + const gitlab = await findGitlabById(input.gitlabId); + await assertGitProviderAccess(ctx.session, gitlab.gitProvider); return await getGitlabBranches(input); }), testConnection: protectedProcedure .input(apiGitlabTestConnection) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { + const gitlab = await findGitlabById(input.gitlabId); + await assertGitProviderAccess(ctx.session, gitlab.gitProvider); const result = await testGitlabConnection(input); return `Found ${result} repositories`; diff --git a/packages/server/src/services/git-provider.ts b/packages/server/src/services/git-provider.ts index 942f94ed2..5a7c6f438 100644 --- a/packages/server/src/services/git-provider.ts +++ b/packages/server/src/services/git-provider.ts @@ -119,3 +119,30 @@ export const getAccessibleGitProviderIds = async (session: { } return result; }; + +/** + * Authorizes read access to a specific git provider for the current session. + * Throws if the provider belongs to a different organization (cross-org IDOR) + * or if the caller is not entitled to it within the active organization. + * Must be called before returning any git-provider record that carries secrets + * (OAuth tokens, app private keys, webhook secrets). + */ +export const assertGitProviderAccess = async ( + session: { userId: string; activeOrganizationId: string }, + provider: { gitProviderId: string; organizationId: string }, +) => { + if (provider.organizationId !== session.activeOrganizationId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Git provider not found", + }); + } + + const accessibleIds = await getAccessibleGitProviderIds(session); + if (!accessibleIds.has(provider.gitProviderId)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have access to this git provider", + }); + } +}; From 26cae3b8a99b282c861feb7e99cda71ab35c96bb Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 19 Jul 2026 21:04:54 -0600 Subject: [PATCH 2/2] fix(types): guard git-provider access check for optional id in getBranches The getBranches inputs type the provider id as optional, so only fetch and authorize when an id is actually present; the underlying getBranches already returns [] when no id is supplied. --- apps/dokploy/server/api/routers/bitbucket.ts | 6 ++++-- apps/dokploy/server/api/routers/github.ts | 6 ++++-- apps/dokploy/server/api/routers/gitlab.ts | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/server/api/routers/bitbucket.ts b/apps/dokploy/server/api/routers/bitbucket.ts index c5a7e41a1..be1437cbd 100644 --- a/apps/dokploy/server/api/routers/bitbucket.ts +++ b/apps/dokploy/server/api/routers/bitbucket.ts @@ -89,8 +89,10 @@ export const bitbucketRouter = createTRPCRouter({ getBitbucketBranches: protectedProcedure .input(apiFindBitbucketBranches) .query(async ({ input, ctx }) => { - const bitbucket = await findBitbucketById(input.bitbucketId); - await assertGitProviderAccess(ctx.session, bitbucket.gitProvider); + if (input.bitbucketId) { + const bitbucket = await findBitbucketById(input.bitbucketId); + await assertGitProviderAccess(ctx.session, bitbucket.gitProvider); + } return await getBitbucketBranches(input); }), testConnection: protectedProcedure diff --git a/apps/dokploy/server/api/routers/github.ts b/apps/dokploy/server/api/routers/github.ts index d27388863..733cc2b83 100644 --- a/apps/dokploy/server/api/routers/github.ts +++ b/apps/dokploy/server/api/routers/github.ts @@ -40,8 +40,10 @@ export const githubRouter = createTRPCRouter({ getGithubBranches: protectedProcedure .input(apiFindGithubBranches) .query(async ({ input, ctx }) => { - const github = await findGithubById(input.githubId); - await assertGitProviderAccess(ctx.session, github.gitProvider); + if (input.githubId) { + const github = await findGithubById(input.githubId); + await assertGitProviderAccess(ctx.session, github.gitProvider); + } return await getGithubBranches(input); }), githubProviders: protectedProcedure.query(async ({ ctx }) => { diff --git a/apps/dokploy/server/api/routers/gitlab.ts b/apps/dokploy/server/api/routers/gitlab.ts index 76b4cd16d..c93637bd1 100644 --- a/apps/dokploy/server/api/routers/gitlab.ts +++ b/apps/dokploy/server/api/routers/gitlab.ts @@ -100,8 +100,10 @@ export const gitlabRouter = createTRPCRouter({ getGitlabBranches: protectedProcedure .input(apiFindGitlabBranches) .query(async ({ input, ctx }) => { - const gitlab = await findGitlabById(input.gitlabId); - await assertGitProviderAccess(ctx.session, gitlab.gitProvider); + if (input.gitlabId) { + const gitlab = await findGitlabById(input.gitlabId); + await assertGitProviderAccess(ctx.session, gitlab.gitProvider); + } return await getGitlabBranches(input); }), testConnection: protectedProcedure