Compare commits

...

7 Commits

Author SHA1 Message Date
Mauricio Siu
1f4f94042f fix: prevent registry password from appearing in error messages and shell commands (#4579) 2026-06-08 09:20:34 -06:00
Mauricio Siu
e9a0932b23 fix: correct git provider access check for existing deploys (#4570)
* fix: use canEditDeployGitSource for git provider access on existing deploys

Replaces the simple userId ownership check with a new canEditDeployGitSource
function that correctly handles all role/sharing scenarios. Owner always has
access; admin and member only if they own the provider or it is shared with
the org — being assigned via accessedGitProviders (enterprise) only grants
permission to connect new deploys, not to edit the git source of existing ones.

Adds 26 unit tests covering owner, admin, member (with/without enterprise
license), shared providers, and the key regression case from issue #4469.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-07 02:10:49 -06:00
Mauricio Siu
6b68fcab8c fix: strip credentials from gitProvider.getAll API response (#4569)
* fix: strip credentials from gitProvider.getAll API response

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-07 01:29:04 -06:00
Mauricio Siu
dfbae18557 fix: correct deriveCookieSecret test to validate 16-byte hex secret as per oauth2-proxy requirements 2026-06-07 01:25:05 -06:00
Mauricio Siu
c1c887d03c fix: update deriveCookieSecret to meet oauth2-proxy requirements 2026-06-07 00:50:20 -06:00
Mauricio Siu
0f77c40ee3 refactor: replace BETTER_AUTH_SECRET with betterAuthSecret in forward-auth setup 2026-06-07 00:28:57 -06:00
Mauricio Siu
a0288f83d5 fix: enforce docker:read on container start/stop/kill/restart mutations (#4568) 2026-06-07 00:18:40 -06:00
12 changed files with 531 additions and 71 deletions

View File

@@ -0,0 +1,369 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
findFirst: 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,
}));
const ORG_ID = "org-1";
const USER_OWNER = "user-owner";
const USER_ADMIN = "user-admin";
const USER_MEMBER = "user-member";
const USER_MEMBER_2 = "user-member-2";
const providerOwned = {
gitProviderId: "gp-owned",
userId: USER_MEMBER,
sharedWithOrganization: false,
};
const providerShared = {
gitProviderId: "gp-shared",
userId: USER_OWNER,
sharedWithOrganization: true,
};
const providerPrivate = {
gitProviderId: "gp-private",
userId: USER_OWNER,
sharedWithOrganization: false,
};
const providerOtherMember = {
gitProviderId: "gp-other",
userId: USER_MEMBER_2,
sharedWithOrganization: false,
};
const allProviders = [
providerOwned,
providerShared,
providerPrivate,
providerOtherMember,
];
function session(userId: string) {
return { userId, activeOrganizationId: ORG_ID };
}
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
mockHasValidLicense.mockResolvedValue(false);
});
describe("getAccessibleGitProviderIds", () => {
describe("owner", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "owner",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members — fixes issue #4469", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("member without enterprise license", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
mockHasValidLicense.mockResolvedValue(false);
});
it("can access their own provider", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("can access shared providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("cannot access private providers of other users even if assigned (no license)", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
it("cannot access providers of other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with enterprise license", () => {
beforeEach(() => {
mockHasValidLicense.mockResolvedValue(true);
});
it("can access provider explicitly assigned to them", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("cannot access provider not assigned and not shared", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
it("can access shared provider even without explicit assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("can access own provider regardless of assignments", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("cannot access provider of other member even with license but no assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with no member record", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue(null);
mockHasValidLicense.mockResolvedValue(true);
});
it("only returns own providers and shared ones", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerShared.gitProviderId)).toBe(true);
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("enterprise license — member assigned to a provider they do not own", () => {
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("empty org", () => {
beforeEach(() => {
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns empty set when org has no providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.size).toBe(0);
});
});
});
describe("canEditDeployGitSource", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(true);
});
describe("owner", () => {
it("can edit deploy using any provider", async () => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_OWNER),
);
expect(result).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
});
it("cannot edit deploy using owner's private provider (not shared)", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(false);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(true);
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_ADMIN,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
"gp-admin-owned",
session(USER_ADMIN),
);
expect(result).toBe(true);
});
});
describe("member", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOwned.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
// This is the key case: enterprise, provider del owner, no compartido,
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("cannot edit deploy using another member's private provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER_2,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOtherMember.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("returns false if provider does not exist", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
const result = await canEditDeployGitSource(
"nonexistent-id",
session(USER_MEMBER),
);
expect(result).toBe(false);
});
});
});

View File

@@ -226,8 +226,8 @@ describe("deriveCookieSecret", () => {
); );
}); });
test("produces a 32-byte base64 secret (oauth2-proxy requirement)", () => { test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
const secret = deriveCookieSecret(".acme.com"); const secret = deriveCookieSecret(".acme.com");
expect(Buffer.from(secret, "base64")).toHaveLength(32); expect(Buffer.from(secret, "hex")).toHaveLength(16);
}); });
}); });

View File

@@ -134,15 +134,10 @@ export const ShowGitProviders = () => {
const canManage = gitProvider.isOwner || isOrgAdmin; const canManage = gitProvider.isOwner || isOrgAdmin;
const haveGithubRequirements = const haveGithubRequirements =
isGithub && isGithub && gitProvider.github?.isConfigured;
gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId;
const haveGitlabRequirements = const haveGitlabRequirements =
isGitlab && isGitlab && gitProvider.gitlab?.isConfigured;
gitProvider.gitlab?.accessToken &&
gitProvider.gitlab?.refreshToken;
return ( return (
<div <div
@@ -230,8 +225,7 @@ export const ShowGitProviders = () => {
)} )}
{isBitbucket && {isBitbucket &&
gitProvider.bitbucket?.appPassword && gitProvider.bitbucket?.isDeprecated ? (
!gitProvider.bitbucket?.apiToken ? (
<Badge variant="yellow">Deprecated</Badge> <Badge variant="yellow">Deprecated</Badge>
) : null} ) : null}
@@ -244,7 +238,7 @@ export const ShowGitProviders = () => {
Action Required Action Required
</Badge> </Badge>
<Link <Link
href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github.githubId}`} href={`${gitProvider?.github?.githubAppName}/installations/new?state=gh_setup:${gitProvider?.github?.githubId}`}
className={buttonVariants({ className={buttonVariants({
size: "icon", size: "icon",
variant: "ghost", variant: "ghost",
@@ -280,7 +274,7 @@ export const ShowGitProviders = () => {
href={getGitlabUrl( href={getGitlabUrl(
gitProvider.gitlab?.applicationId || "", gitProvider.gitlab?.applicationId || "",
gitProvider.gitlab?.gitlabId || "", gitProvider.gitlab?.gitlabId || "",
gitProvider.gitlab?.gitlabUrl, gitProvider.gitlab?.gitlabUrl || "",
)} )}
target="_blank" target="_blank"
className={buttonVariants({ className={buttonVariants({
@@ -295,29 +289,33 @@ export const ShowGitProviders = () => {
{canManage && ( {canManage && (
<> <>
{isGithub && haveGithubRequirements && ( {isGithub &&
<EditGithubProvider haveGithubRequirements &&
githubId={gitProvider.github?.githubId} gitProvider.github?.githubId && (
/> <EditGithubProvider
)} githubId={gitProvider.github.githubId}
/>
)}
{isGitlab && ( {isGitlab &&
<EditGitlabProvider gitProvider.gitlab?.gitlabId && (
gitlabId={gitProvider.gitlab?.gitlabId} <EditGitlabProvider
/> gitlabId={gitProvider.gitlab.gitlabId}
)} />
)}
{isBitbucket && ( {isBitbucket &&
<EditBitbucketProvider gitProvider.bitbucket?.bitbucketId && (
bitbucketId={ <EditBitbucketProvider
gitProvider.bitbucket?.bitbucketId bitbucketId={
} gitProvider.bitbucket.bitbucketId
/> }
)} />
)}
{isGitea && ( {isGitea && gitProvider.gitea?.giteaId && (
<EditGiteaProvider <EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId} giteaId={gitProvider.gitea.giteaId}
/> />
)} )}

View File

@@ -4,7 +4,6 @@ import {
deleteAllMiddlewares, deleteAllMiddlewares,
findApplicationById, findApplicationById,
findEnvironmentById, findEnvironmentById,
findGitProviderById,
findProjectById, findProjectById,
getAccessibleServerIds, getAccessibleServerIds,
getApplicationStats, getApplicationStats,
@@ -31,6 +30,7 @@ import {
writeConfigRemote, writeConfigRemote,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import { import {
addNewService, addNewService,
checkServiceAccess, checkServiceAccess,
@@ -174,13 +174,11 @@ export const applicationRouter = createTRPCRouter({
const gitProviderId = getGitProviderId(); const gitProviderId = getGitProviderId();
if (gitProviderId) { if (gitProviderId) {
try { const canEdit = await canEditDeployGitSource(
const gitProvider = await findGitProviderById(gitProviderId); gitProviderId,
if (gitProvider.userId !== ctx.session.userId) { ctx.session,
hasGitProviderAccess = false; );
unauthorizedProvider = application.sourceType; if (!canEdit) {
}
} catch {
hasGitProviderAccess = false; hasGitProviderAccess = false;
unauthorizedProvider = application.sourceType; unauthorizedProvider = application.sourceType;
} }

View File

@@ -13,7 +13,6 @@ import {
findComposeById, findComposeById,
findDomainsByComposeId, findDomainsByComposeId,
findEnvironmentById, findEnvironmentById,
findGitProviderById,
findProjectById, findProjectById,
findServerById, findServerById,
getAccessibleServerIds, getAccessibleServerIds,
@@ -34,6 +33,7 @@ import {
updateDeploymentStatus, updateDeploymentStatus,
} from "@dokploy/server"; } from "@dokploy/server";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import { import {
addNewService, addNewService,
checkServiceAccess, checkServiceAccess,
@@ -173,13 +173,11 @@ export const composeRouter = createTRPCRouter({
const gitProviderId = getGitProviderId(); const gitProviderId = getGitProviderId();
if (gitProviderId) { if (gitProviderId) {
try { const canEdit = await canEditDeployGitSource(
const gitProvider = await findGitProviderById(gitProviderId); gitProviderId,
if (gitProvider.userId !== ctx.session.userId) { ctx.session,
hasGitProviderAccess = false; );
unauthorizedProvider = compose.sourceType; if (!canEdit) {
}
} catch {
hasGitProviderAccess = false; hasGitProviderAccess = false;
unauthorizedProvider = compose.sourceType; unauthorizedProvider = compose.sourceType;
} }

View File

@@ -38,7 +38,7 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId); return await getContainers(input.serverId);
}), }),
restartContainer: withPermission("service", "read") restartContainer: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
containerId: z containerId: z
@@ -64,7 +64,7 @@ export const dockerRouter = createTRPCRouter({
}); });
}), }),
startContainer: withPermission("service", "read") startContainer: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
containerId: z containerId: z
@@ -90,7 +90,7 @@ export const dockerRouter = createTRPCRouter({
}); });
}), }),
stopContainer: withPermission("service", "read") stopContainer: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
containerId: z containerId: z
@@ -116,7 +116,7 @@ export const dockerRouter = createTRPCRouter({
}); });
}), }),
killContainer: withPermission("service", "read") killContainer: withPermission("docker", "read")
.input( .input(
z.object({ z.object({
containerId: z containerId: z

View File

@@ -42,6 +42,43 @@ export const gitProviderRouter = createTRPCRouter({
return results.map((r) => ({ return results.map((r) => ({
...r, ...r,
isOwner: r.userId === ctx.session.userId, isOwner: r.userId === ctx.session.userId,
github: r.github
? {
githubId: r.github.githubId,
githubAppName: r.github.githubAppName,
githubAppId: r.github.githubAppId,
githubInstallationId: r.github.githubInstallationId,
isConfigured: !!(
r.github.githubPrivateKey &&
r.github.githubAppId &&
r.github.githubInstallationId
),
}
: null,
gitlab: r.gitlab
? {
gitlabId: r.gitlab.gitlabId,
applicationId: r.gitlab.applicationId,
gitlabUrl: r.gitlab.gitlabUrl,
isConfigured: !!(r.gitlab.accessToken && r.gitlab.refreshToken),
}
: null,
bitbucket: r.bitbucket
? {
bitbucketId: r.bitbucket.bitbucketId,
bitbucketUsername: r.bitbucket.bitbucketUsername,
isConfigured: false,
isDeprecated: !!(r.bitbucket.appPassword && !r.bitbucket.apiToken),
}
: null,
gitea: r.gitea
? {
giteaId: r.gitea.giteaId,
giteaUrl: r.gitea.giteaUrl,
clientId: r.gitea.clientId,
isConfigured: !!(r.gitea.accessToken && r.gitea.refreshToken),
}
: null,
})); }));
}), }),

View File

@@ -43,6 +43,38 @@ export const updateGitProvider = async (
.then((response) => response[0]); .then((response) => response[0]);
}; };
// Returns true if the user can edit the git source configuration of an existing
// deploy that is connected to the given provider.
// Owner/admin: always yes.
// Member: only if they own the provider or it's shared with the org.
// Being in accessedGitProviders only grants permission to connect NEW deploys,
// not to modify the git config of an existing deploy owned by someone else.
export const canEditDeployGitSource = async (
gitProviderId: string,
session: { userId: string; activeOrganizationId: string },
): Promise<boolean> => {
const { userId, activeOrganizationId } = session;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { role: true },
});
if (memberRecord?.role === "owner") return true;
const provider = await db.query.gitProvider.findFirst({
where: eq(gitProvider.gitProviderId, gitProviderId),
columns: { userId: true, sharedWithOrganization: true },
});
if (!provider) return false;
return provider.userId === userId || provider.sharedWithOrganization;
};
export const getAccessibleGitProviderIds = async (session: { export const getAccessibleGitProviderIds = async (session: {
userId: string; userId: string;
activeOrganizationId: string; activeOrganizationId: string;

View File

@@ -27,6 +27,16 @@ export function safeDockerLoginCommand(
return `printf %s ${escapedPassword} | docker login ${escapedRegistry} -u ${escapedUser} --password-stdin`; return `printf %s ${escapedPassword} | docker login ${escapedRegistry} -u ${escapedUser} --password-stdin`;
} }
function sanitizeRegistryError(
error: unknown,
password: string | null | undefined,
): string {
const message =
error instanceof Error ? error.message : "Error with registry login";
if (!password) return message;
return message.split(password).join("***");
}
export const createRegistry = async ( export const createRegistry = async (
input: z.infer<typeof apiCreateRegistry>, input: z.infer<typeof apiCreateRegistry>,
organizationId: string, organizationId: string,
@@ -59,10 +69,15 @@ export const createRegistry = async (
input.username, input.username,
input.password, input.password,
); );
if (input.serverId && input.serverId !== "none") { try {
await execAsyncRemote(input.serverId, loginCommand); if (input.serverId && input.serverId !== "none") {
} else if (newRegistry.registryType === "cloud") { await execAsyncRemote(input.serverId, loginCommand);
await execAsync(loginCommand); } else if (newRegistry.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (error) {
const sanitized = sanitizeRegistryError(error, input.password);
throw new TRPCError({ code: "BAD_REQUEST", message: sanitized });
} }
return newRegistry; return newRegistry;
@@ -129,16 +144,24 @@ export const updateRegistry = async (
}); });
} }
if (registryData?.serverId && registryData?.serverId !== "none") { try {
await execAsyncRemote(registryData.serverId, loginCommand); if (registryData?.serverId && registryData?.serverId !== "none") {
} else if (response?.registryType === "cloud") { await execAsyncRemote(registryData.serverId, loginCommand);
await execAsync(loginCommand); } else if (response?.registryType === "cloud") {
await execAsync(loginCommand);
}
} catch (execError) {
throw new Error(sanitizeRegistryError(execError, response?.password));
} }
return response; return response;
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Error updating this registry"; error instanceof TRPCError
? error.message
: error instanceof Error
? error.message
: "Error updating this registry";
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message, message,

View File

@@ -1,5 +1,6 @@
import { createHmac } from "node:crypto"; import { createHmac } from "node:crypto";
import type { CreateServiceOptions } from "dockerode"; import type { CreateServiceOptions } from "dockerode";
import { betterAuthSecret } from "../lib/auth-secret";
import { getRemoteDocker } from "../utils/servers/remote-docker"; import { getRemoteDocker } from "../utils/servers/remote-docker";
export const FORWARD_AUTH_SERVICE_NAME = "dokploy-forward-auth"; export const FORWARD_AUTH_SERVICE_NAME = "dokploy-forward-auth";
@@ -37,15 +38,12 @@ export const forwardAuthCallbackUrl = (
): string => `${https ? "https" : "http"}://${authDomain}/oauth2/callback`; ): string => `${https ? "https" : "http"}://${authDomain}/oauth2/callback`;
export const deriveCookieSecret = (salt: string): string => { export const deriveCookieSecret = (salt: string): string => {
const rootSecret = process.env.BETTER_AUTH_SECRET; // oauth2-proxy requires cookie_secret to be exactly 16, 24, or 32 bytes.
if (!rootSecret) { // Take the first 32 hex chars (= 16 bytes) to satisfy that constraint.
throw new Error( return createHmac("sha256", betterAuthSecret)
"BETTER_AUTH_SECRET is required to derive the forward-auth cookie secret",
);
}
return createHmac("sha256", rootSecret)
.update(`forward-auth:${salt}`) .update(`forward-auth:${salt}`)
.digest("base64"); .digest("hex")
.slice(0, 32);
}; };
export const buildForwardAuthEnv = ( export const buildForwardAuthEnv = (

View File

@@ -1,6 +1,7 @@
import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment"; import { findAllDeploymentsByApplicationId } from "@dokploy/server/services/deployment";
import { import {
findRegistryByIdWithCredentials, findRegistryByIdWithCredentials,
safeDockerLoginCommand,
type Registry, type Registry,
} from "@dokploy/server/services/registry"; } from "@dokploy/server/services/registry";
import { createRollback } from "@dokploy/server/services/rollbacks"; import { createRollback } from "@dokploy/server/services/rollbacks";
@@ -117,9 +118,14 @@ const getRegistryCommands = (
imageName: string, imageName: string,
registryTag: string, registryTag: string,
): string => { ): string => {
const loginCmd = safeDockerLoginCommand(
registry.registryUrl,
registry.username,
registry.password,
);
return ` return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ; echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo "${registry.password}" | docker login ${registry.registryUrl} -u '${registry.username}' --password-stdin || { ${loginCmd} || {
echo "❌ DockerHub Failed" ; echo "❌ DockerHub Failed" ;
exit 1; exit 1;
} }

View File

@@ -1,3 +1,4 @@
import { safeDockerLoginCommand } from "@dokploy/server/services/registry";
import type { ApplicationNested } from "../builders"; import type { ApplicationNested } from "../builders";
export const buildRemoteDocker = async (application: ApplicationNested) => { export const buildRemoteDocker = async (application: ApplicationNested) => {
@@ -13,7 +14,7 @@ echo "Pulling ${dockerImage}";
if (username && password) { if (username && password) {
command += ` command += `
if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" 2>&1; then if ! ${safeDockerLoginCommand(registryUrl || "", username, password)} 2>&1; then
echo "❌ Login failed"; echo "❌ Login failed";
exit 1; exit 1;
fi fi