mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-21 13:55:33 +02:00
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
This commit is contained in:
91
apps/dokploy/__test__/git-provider/git-provider-idor.test.ts
Normal file
91
apps/dokploy/__test__/git-provider/git-provider-idor.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user