Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions apps/dokploy/__test__/security/database-environment-reveal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
findMySqlById: vi.fn(),
findPostgresById: vi.fn(),
findRedisById: vi.fn(),
hasPermission: vi.fn(),
getServiceContainerCommand: vi.fn(
(appName: string) =>
`docker ps -q --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`,
Expand Down Expand Up @@ -96,6 +97,7 @@ vi.mock("@dokploy/server/services/permission", () => ({
checkServiceAccess: mocks.checkServiceAccess,
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
findMemberByUserId: mocks.noop,
hasPermission: mocks.hasPermission,
}));

vi.mock("@/server/api/utils/audit", () => ({
Expand Down Expand Up @@ -141,7 +143,9 @@ const databaseService = (
databasePassword: `${id}-password`,
databaseRootPassword: `${id}-root-password`,
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: {
env: "PROJECT_SHARED=secret",
organizationId,
},
},
Expand Down Expand Up @@ -191,6 +195,7 @@ describe("database environment reveal boundary", () => {
vi.clearAllMocks();
mocks.checkServiceAccess.mockResolvedValue(undefined);
mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined);
mocks.hasPermission.mockResolvedValue(false);
for (const testCase of cases) {
testCase.find.mockResolvedValue(
databaseService(testCase.idField, testCase.id),
Expand Down Expand Up @@ -221,4 +226,39 @@ describe("database environment reveal boundary", () => {
);
},
);

it.each(cases)(
"hides shared env in $id reads without shared env read permissions",
async (testCase) => {
const normalRead = await testCase.router
.createCaller(createContext())
.one({ [testCase.idField]: testCase.id } as never);

expect(normalRead.environment).toEqual({
env: REDACTED_SECRET_VALUE,
project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" },
});
},
);

it.each(cases)(
"keeps shared env in $id reads for callers that can read it",
async (testCase) => {
mocks.hasPermission.mockImplementation(
async (_ctx: unknown, permissions: Record<string, string[]>) =>
"environmentEnvVars" in permissions ||
"projectEnvVars" in permissions,
);

const normalRead = await testCase.router
.createCaller(createContext())
.one({ [testCase.idField]: testCase.id } as never);

expect(normalRead.env).toBe(REDACTED_SECRET_VALUE);
expect(normalRead.environment).toEqual({
env: "ENVIRONMENT_SHARED=secret",
project: { env: "PROJECT_SHARED=secret", organizationId: "org-1" },
});
},
);
});
73 changes: 73 additions & 0 deletions apps/dokploy/__test__/security/secret-redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
REDACTED_SECRET_VALUE,
redactBackupScheduleSecrets,
redactDatabaseServiceSecrets,
redactDatabaseServiceSecretsFor,
redactDeployableServiceSecrets,
redactDeployableServiceSecretsFor,
redactProjectNestedSecrets,
redactRollbackFullContextSecrets,
redactSecretFields,
Expand Down Expand Up @@ -157,6 +159,77 @@ describe("shared secret redaction helpers", () => {
});
});

it("redacts git url credentials and shared env from deployable service reads", () => {
const redacted = redactDeployableServiceSecrets({
customGitUrl: "https://user:token@example.com/org/repo.git",
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: { env: "PROJECT_SHARED=secret", organizationId: "org-1" },
},
});

expect(redacted).toEqual({
customGitUrl: `https://${REDACTED_SECRET_VALUE}@example.com/org/repo.git`,
environment: {
env: REDACTED_SECRET_VALUE,
project: { env: REDACTED_SECRET_VALUE, organizationId: "org-1" },
},
});
});

it("keeps only the shared env a reader may read", () => {
const service = {
env: "TOKEN=secret",
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: { env: "PROJECT_SHARED=secret" },
},
};

expect(
redactDeployableServiceSecretsFor(service, {
environmentEnv: true,
projectEnv: false,
}),
).toEqual({
env: REDACTED_SECRET_VALUE,
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: { env: REDACTED_SECRET_VALUE },
},
});
expect(
redactDatabaseServiceSecretsFor(service, {
environmentEnv: false,
projectEnv: true,
}),
).toEqual({
env: REDACTED_SECRET_VALUE,
environment: {
env: REDACTED_SECRET_VALUE,
project: { env: "PROJECT_SHARED=secret" },
},
});
});

it("redacts shared env from database service reads", () => {
expect(
redactDatabaseServiceSecrets({
databasePassword: "secret",
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: { env: "PROJECT_SHARED=secret" },
},
}),
).toEqual({
databasePassword: REDACTED_SECRET_VALUE,
environment: {
env: REDACTED_SECRET_VALUE,
project: { env: REDACTED_SECRET_VALUE },
},
});
});

it("redacts database service read credentials", () => {
const redacted = redactDatabaseServiceSecrets({
env: "PGSSLMODE=require",
Expand Down
83 changes: 83 additions & 0 deletions apps/dokploy/__test__/security/service-environment-reveal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,11 @@ const application = (organizationId = "org-1") => ({
env: "APP_SECRET=secret",
buildArgs: "BUILD_ARG_SECRET=secret",
buildSecrets: "BUILD_SECRET=secret",
customGitUrl: "https://app-token@example.com/org/private.git",
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: {
env: "PROJECT_SHARED=secret",
organizationId,
},
},
Expand All @@ -242,14 +245,29 @@ const compose = (organizationId = "org-1") => ({
"services:\n api:\n environment:\n TOKEN: compose-secret",
customGitUrl: "https://compose-token@example.com/org/private.git",
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: {
env: "PROJECT_SHARED=secret",
organizationId,
},
},
});

const redactedCustomGitUrl = `https://${REDACTED_SECRET_VALUE}@example.com/org/private.git`;

const redactedSharedEnvironment = {
env: REDACTED_SECRET_VALUE,
project: {
env: REDACTED_SECRET_VALUE,
organizationId: "org-1",
},
};

const canReadSharedEnv = async (
_ctx: unknown,
permissions: Record<string, string[]>,
) => "environmentEnvVars" in permissions || "projectEnvVars" in permissions;

describe("service environment reveal boundary", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -347,6 +365,71 @@ describe("service environment reveal boundary", () => {
});
});

it("hides shared env and git url credentials in application reads", async () => {
const normalRead = await applicationRouter
.createCaller(createContext())
.one({ applicationId: "app-1" });

expect(normalRead).toMatchObject({
customGitUrl: redactedCustomGitUrl,
environment: redactedSharedEnvironment,
});
expect(permissionMocks.hasPermission).toHaveBeenCalledWith(
expect.anything(),
{ environmentEnvVars: ["read"] },
);
expect(permissionMocks.hasPermission).toHaveBeenCalledWith(
expect.anything(),
{ projectEnvVars: ["read"] },
);
});

it("keeps shared env in application and compose reads for callers that can read it", async () => {
permissionMocks.hasPermission.mockImplementation(canReadSharedEnv);

const applicationRead = await applicationRouter
.createCaller(createContext())
.one({ applicationId: "app-1" });
const composeRead = await composeRouter
.createCaller(createContext())
.one({ composeId: "compose-1" });

for (const read of [applicationRead, composeRead]) {
expect(read).toMatchObject({
env: REDACTED_SECRET_VALUE,
customGitUrl: redactedCustomGitUrl,
environment: {
env: "ENVIRONMENT_SHARED=secret",
project: { env: "PROJECT_SHARED=secret" },
},
});
}
});

it("hides shared env in compose reads", async () => {
const normalRead = await composeRouter
.createCaller(createContext())
.one({ composeId: "compose-1" });

expect(normalRead).toMatchObject({
environment: redactedSharedEnvironment,
});
});

it("hides shared env and git url credentials in application action responses", async () => {
permissionMocks.hasPermission.mockImplementation(canReadSharedEnv);

const stopped = await applicationRouter
.createCaller(createContext())
.stop({ applicationId: "app-1" });

expect(stopped).toMatchObject({
env: REDACTED_SECRET_VALUE,
customGitUrl: redactedCustomGitUrl,
environment: redactedSharedEnvironment,
});
});

it("denies reveal when the service belongs to another organization", async () => {
serverMocks.findApplicationById.mockResolvedValueOnce(application("org-2"));

Expand Down
9 changes: 7 additions & 2 deletions apps/dokploy/server/api/routers/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { assertCustomGitUrlAllowed } from "@dokploy/server/utils/providers/git";
import {
preserveSecretPlaceholderFields,
redactDeployableServiceSecrets,
redactDeployableServiceSecretsFor,
} from "@dokploy/server/utils/security/redaction";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
Expand All @@ -66,7 +67,10 @@ import { audit } from "@/server/api/utils/audit";
import { assertDeploySourceCredentialAccess } from "@/server/api/utils/deploy-source-access";
import { assertContainerMetricsServiceAccess } from "@/server/api/utils/monitoring-access";
import { assertTargetEnvironmentAccess } from "@/server/api/utils/placement-access";
import { assertServiceEnvironmentReadAccess } from "@/server/api/utils/service-environment";
import {
assertServiceEnvironmentReadAccess,
getSharedEnvReadAccess,
} from "@/server/api/utils/service-environment";
import {
apiCreateApplication,
apiDeployApplication,
Expand Down Expand Up @@ -445,8 +449,9 @@ export const applicationRouter = createTRPCRouter({
}

return {
...redactDeployableServiceSecrets(
...redactDeployableServiceSecretsFor(
redactGitProviderSecrets(application),
await getSharedEnvReadAccess(ctx),
),
hasGitProviderAccess,
unauthorizedProvider,
Expand Down
55 changes: 24 additions & 31 deletions apps/dokploy/server/api/routers/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ import { processTemplate } from "@dokploy/server/templates/processors";
import { assertCustomGitUrlAllowed } from "@dokploy/server/utils/providers/git";
import {
preserveSecretPlaceholderFields,
redactDeployableServiceSecrets,
redactDeployableServiceSecretsFor,
redactSecretFields,
redactSensitiveText,
type SharedEnvReadAccess,
} from "@dokploy/server/utils/security/redaction";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
Expand Down Expand Up @@ -102,46 +102,37 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
import { audit } from "../utils/audit";
import { assertDeploySourceCredentialAccess } from "../utils/deploy-source-access";
import { assertTargetEnvironmentAccess } from "../utils/placement-access";
import { assertServiceEnvironmentReadAccess } from "../utils/service-environment";
import {
assertServiceEnvironmentReadAccess,
getSharedEnvReadAccess,
} from "../utils/service-environment";

type SecretRecord = Record<string, unknown>;

const redactCustomGitUrl = <T extends SecretRecord | null | undefined>(
record: T,
) => {
if (!record) {
return record;
}

const redacted = { ...record };
if ("customGitUrl" in redacted) {
redacted.customGitUrl = redactSensitiveText(
redacted.customGitUrl as string | null | undefined,
);
}

return redacted as T;
};

const redactComposeSecrets = <T extends SecretRecord | null | undefined>(
record: T,
{ redactComposeFile = true }: { redactComposeFile?: boolean } = {},
{
redactComposeFile = true,
sharedEnvAccess,
}: {
redactComposeFile?: boolean;
sharedEnvAccess?: SharedEnvReadAccess;
} = {},
) => {
if (!record) {
return record;
}

const redacted = redactCustomGitUrl(
redactDeployableServiceSecrets(
redactGitProviderSecrets(
record as T & {
bitbucket?: object | null;
gitea?: object | null;
github?: object | null;
gitlab?: object | null;
},
),
const redacted = redactDeployableServiceSecretsFor(
redactGitProviderSecrets(
record as T & {
bitbucket?: object | null;
gitea?: object | null;
github?: object | null;
gitlab?: object | null;
},
),
sharedEnvAccess,
);

return redactComposeFile
Expand Down Expand Up @@ -360,11 +351,13 @@ export const composeRouter = createTRPCRouter({
}

const canReadEnvVars = await hasPermission(ctx, { envVars: ["read"] });
const sharedEnvAccess = await getSharedEnvReadAccess(ctx);

return {
// The compose file editor loads composeFile from this response.
...redactComposeSecrets(compose, {
redactComposeFile: !canReadEnvVars,
sharedEnvAccess,
}),
hasGitProviderAccess,
unauthorizedProvider,
Expand Down
Loading
Loading