Compare commits

..

3 Commits

Author SHA1 Message Date
Mauricio Siu
556e1cf92d docs: trim block comment on assertGitProviderAccess 2026-07-19 21:37:21 -06:00
Mauricio Siu
26cae3b8a9 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.
2026-07-19 21:04:54 -06:00
Mauricio Siu
071d9eacee 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
2026-07-19 20:55:53 -06:00
9 changed files with 190 additions and 86 deletions

View 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);
});
});

View File

@@ -1,44 +0,0 @@
import { redactServerSshKey } from "@dokploy/server/services/server";
import { describe, expect, it } from "vitest";
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
it("blanks the private key while keeping the rest of the ssh key intact", () => {
const server = {
serverId: "srv-1",
name: "prod",
sshKey: {
sshKeyId: "key-1",
publicKey: "ssh-ed25519 AAAA...",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
},
};
const redacted = redactServerSshKey(server);
expect(redacted.sshKey.privateKey).toBe("");
// Non-secret fields and the surrounding record must survive untouched.
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
expect(redacted.serverId).toBe("srv-1");
expect(redacted.name).toBe("prod");
});
it("does not mutate the original record", () => {
const server = {
serverId: "srv-1",
sshKey: { privateKey: "top-secret" },
};
redactServerSshKey(server);
expect(server.sshKey.privateKey).toBe("top-secret");
});
it("is a no-op when the server has no ssh key", () => {
const server = { serverId: "srv-2", sshKey: null };
expect(redactServerSshKey(server)).toEqual(server);
});
it("handles a record without a loaded sshKey relation", () => {
// e.g. server.update returns the plain row where sshKey is not populated.
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
expect(redactServerSshKey(server)).toEqual(server);
});
});

View File

@@ -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,26 @@ 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 }) => {
if (input.bitbucketId) {
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`;

View File

@@ -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,

View File

@@ -1,4 +1,5 @@
import {
assertGitProviderAccess,
findGithubById,
getAccessibleGitProviderIds,
getGithubBranches,
@@ -22,17 +23,27 @@ 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 }) => {
if (input.githubId) {
const github = await findGithubById(input.githubId);
await assertGitProviderAccess(ctx.session, github.gitProvider);
}
return await getGithubBranches(input);
}),
githubProviders: protectedProcedure.query(async ({ ctx }) => {
@@ -67,8 +78,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) {

View File

@@ -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,27 @@ 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 }) => {
if (input.gitlabId) {
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`;

View File

@@ -9,7 +9,6 @@ import {
getPublicIpWithFallback,
haveActiveServices,
IS_CLOUD,
redactServerSshKey,
removeDeploymentsByServerId,
serverAudit,
serverSetup,
@@ -106,7 +105,7 @@ export const serverRouter = createTRPCRouter({
});
}
return redactServerSshKey(server);
return server;
}),
getDefaultCommand: withPermission("server", "read")
.input(apiFindOneServer)
@@ -437,7 +436,7 @@ export const serverRouter = createTRPCRouter({
await updateServersBasedOnQuantity(admin.id, admin.serversQuantity);
}
return redactServerSshKey(currentServer);
return currentServer;
} catch (error) {
throw error;
}

View File

@@ -119,3 +119,26 @@ export const getAccessibleGitProviderIds = async (session: {
}
return result;
};
// Throws if the provider is in another organization (NOT_FOUND) or the caller
// is not entitled to it in the active org (FORBIDDEN). Call before returning any
// git-provider record that carries 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",
});
}
};

View File

@@ -53,22 +53,6 @@ export const findServerById = async (serverId: string) => {
return currentServer;
};
// Blanks the SSH private key before a server record is sent to a client
// (server-side SSH callers use findServerById directly, unredacted).
export const redactServerSshKey = <
T extends { sshKey?: { privateKey: string } | null },
>(
serverRecord: T,
): T => {
if (!serverRecord.sshKey) {
return serverRecord;
}
return {
...serverRecord,
sshKey: { ...serverRecord.sshKey, privateKey: "" },
};
};
export const findServersByUserId = async (userId: string) => {
const orgs = await db.query.organization.findMany({
where: eq(organization.ownerId, userId),