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", + }); + } +};