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
220 changes: 220 additions & 0 deletions apps/dokploy/__test__/monitoring/container-metrics-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

type Service = {
appName: string;
serverId: string | null;
organizationId: string;
id: string;
};

const mocks = vi.hoisted(() => {
const services = new Map<string, Map<string, Service>>();
const idColumns: Record<string, string> = {
applications: "applicationId",
compose: "composeId",
postgres: "postgresId",
mysql: "mysqlId",
mariadb: "mariadbId",
mongo: "mongoId",
redis: "redisId",
libsql: "libsqlId",
previewDeployments: "previewDeploymentId",
};
const findFirst =
(table: string) =>
async ({ where }: { where: { value: string } }) => {
const service = services.get(table)?.get(where.value);
if (!service) return undefined;
return {
[idColumns[table] as string]: service.id,
appName: service.appName,
serverId: service.serverId,
environment: {
project: { organizationId: service.organizationId },
},
};
};
return {
services,
query: Object.fromEntries(
Object.keys(idColumns).map((table) => [
table,
{ findFirst: findFirst(table) },
]),
),
execAsync: vi.fn(),
checkServicePermissionAndAccess: vi.fn(),
};
});

vi.mock("drizzle-orm", async (importOriginal) => ({
...(await importOriginal<typeof import("drizzle-orm")>()),
eq: (_column: unknown, value: unknown) => ({ value }),
}));
vi.mock("@dokploy/server/db", () => ({
db: { query: mocks.query },
}));
vi.mock("@dokploy/server/services/permission", () => ({
checkServicePermissionAndAccess: mocks.checkServicePermissionAndAccess,
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
}));

import { assertContainerMetricsServiceAccess } from "@/server/api/utils/monitoring-access";

const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};

const addService = (table: string, service: Service) => {
const rows = mocks.services.get(table) ?? new Map<string, Service>();
rows.set(service.appName, service);
mocks.services.set(table, rows);
};

const inspectResult = (name: string, labels: Record<string, string> | null) =>
mocks.execAsync.mockResolvedValue({
stdout: `${JSON.stringify({ name, labels })}\n`,
stderr: "",
});

beforeEach(() => {
vi.clearAllMocks();
mocks.services.clear();
mocks.checkServicePermissionAndAccess.mockResolvedValue(undefined);
addService("compose", {
id: "compose-1",
appName: "shop-abc123",
serverId: null,
organizationId: "org-1",
});
});

describe("assertContainerMetricsServiceAccess", () => {
it("checks access to the compose service that owns a container", async () => {
inspectResult("/shop-abc123-worker-1", {
"com.docker.compose.project": "shop-abc123",
"com.docker.compose.service": "worker",
});

await assertContainerMetricsServiceAccess(ctx, "shop-abc123-worker-1");

expect(mocks.execAsync).toHaveBeenCalledWith(
expect.stringContaining("shop-abc123-worker-1"),
);
expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith(
ctx,
"compose-1",
{ monitoring: ["read"] },
);
});

it("checks access to the stack that owns a task container", async () => {
addService("compose", {
id: "stack-1",
appName: "blog-def456",
serverId: null,
organizationId: "org-1",
});
inspectResult("/blog-def456_web.1.k2j3h4", {
"com.docker.stack.namespace": "blog-def456",
"com.docker.swarm.service.name": "blog-def456_web",
});

await assertContainerMetricsServiceAccess(ctx, "blog-def456_web.1.k2j3h4");

expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith(
ctx,
"stack-1",
{ monitoring: ["read"] },
);
});

it("does not inspect containers when the name is a service", async () => {
await assertContainerMetricsServiceAccess(ctx, "shop-abc123");

expect(mocks.execAsync).not.toHaveBeenCalled();
expect(mocks.checkServicePermissionAndAccess).toHaveBeenCalledWith(
ctx,
"compose-1",
{ monitoring: ["read"] },
);
});

it("denies a container resolved by an ID prefix instead of its name", async () => {
inspectResult("/shop-abc123-worker-1", {
"com.docker.compose.project": "shop-abc123",
});

await expect(
assertContainerMetricsServiceAccess(ctx, "3f9a1c"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled();
});

it("denies a container whose service is in another organization", async () => {
addService("compose", {
id: "compose-2",
appName: "other-xyz789",
serverId: null,
organizationId: "org-2",
});
inspectResult("/other-xyz789-db-1", {
"com.docker.compose.project": "other-xyz789",
});

await expect(
assertContainerMetricsServiceAccess(ctx, "other-xyz789-db-1"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled();
});

it("ignores labels that point at a service on a remote server", async () => {
addService("compose", {
id: "compose-3",
appName: "remote-qwe321",
serverId: "server-1",
organizationId: "org-1",
});
inspectResult("/remote-qwe321-api-1", {
"com.docker.compose.project": "remote-qwe321",
});

await expect(
assertContainerMetricsServiceAccess(ctx, "remote-qwe321-api-1"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
});

it("denies containers without labels or when docker fails", async () => {
inspectResult("/loose-container", null);
await expect(
assertContainerMetricsServiceAccess(ctx, "loose-container"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });

mocks.execAsync.mockRejectedValue(new Error("No such container"));
await expect(
assertContainerMetricsServiceAccess(ctx, "missing-container"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.checkServicePermissionAndAccess).not.toHaveBeenCalled();
});

it("does not resolve containers for a metrics server request", async () => {
await expect(
assertContainerMetricsServiceAccess(
ctx,
"shop-abc123-worker-1",
"server-1",
),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.execAsync).not.toHaveBeenCalled();
});

it("rejects names that are not docker identifiers without running docker", async () => {
await expect(
assertContainerMetricsServiceAccess(ctx, "x; rm -rf /"),
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
expect(mocks.execAsync).not.toHaveBeenCalled();
});
});
25 changes: 25 additions & 0 deletions apps/dokploy/__test__/wss/docker-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ vi.mock("@/server/wss/authorize", () => ({
vi.mock("@/server/wss/server-permission", () => ({
canAccessMonitoringWebSocket: vi.fn(async () => true),
}));
const mockAssertMetricsAccess = vi.hoisted(() => vi.fn());
vi.mock("@/server/api/utils/monitoring-access", () => ({
assertContainerMetricsServiceAccess: mockAssertMetricsAccess,
}));

import { setupDockerStatsMonitoringSocketServer } from "@/server/wss/docker-stats";

Expand Down Expand Up @@ -111,4 +115,25 @@ describe("docker stats websocket (#5504)", () => {

expect(mockListContainers).not.toHaveBeenCalled();
});

it("tells the client why it was refused instead of closing silently", async () => {
mockValidateRequest.mockResolvedValue(AUTH);
mockAssertMetricsAccess.mockRejectedValue(new Error("UNAUTHORIZED"));
const ws = new WebSocket(
url.replace(
"appName=dokploy",
"appName=shop-abc123-worker-1&appType=docker-compose",
),
);

const [code, reason] = await new Promise<[number, string]>((r) =>
ws.once("close", (closeCode, closeReason) =>
r([closeCode, closeReason.toString()]),
),
);

expect(code).toBe(4003);
expect(reason).toBe("Not authorized");
expect(mockListContainers).not.toHaveBeenCalled();
});
});
63 changes: 62 additions & 1 deletion apps/dokploy/server/api/utils/monitoring-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import {
redis,
} from "@dokploy/server/db/schema";
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";

type MonitoringAccessCtx = {
session: {
Expand Down Expand Up @@ -384,6 +386,61 @@ export const findAccessibleContainerResourceStat = async <
);
};

const CONTAINER_OWNER_LABELS = [
"com.docker.compose.project",
"com.docker.stack.namespace",
"com.docker.swarm.service.name",
];

// Compose and stack containers are named "<project>-<service>-1" or
// "<stack>_<service>.1.<task>", not after the Dokploy service, so the monitoring
// tab asks for a container name that matches no service. The owning service is
// then taken from the local container's labels; only services on this host
// count, because the lookup only sees local containers.
const findLocalContainerMonitoringService = async (containerName: string) => {
const name = normalizeDockerLabelValue(containerName);
if (!name) {
return null;
}

let container: { name?: string; labels?: Record<string, string> | null };
try {
const { stdout } = await execAsync(
quote([
"docker",
"inspect",
"--type",
"container",
"--format",
'{"name":{{json .Name}},"labels":{{json .Config.Labels}}}',
name,
]),
);
container = JSON.parse(stdout.trim());
} catch {
return null;
}

// `docker inspect` also resolves ID prefixes; only an exact name counts.
if (container.name !== `/${name}`) {
return null;
}

const labels = parseDockerLabels(container.labels);
for (const label of CONTAINER_OWNER_LABELS) {
const ownerAppName = normalizeDockerLabelValue(labels[label]);
if (!ownerAppName) {
continue;
}
const service = await findMonitoringServiceByAppName(ownerAppName);
if (service && !service.serverId) {
return service;
}
}

return null;
};

export const assertContainerMetricsServiceAccess = async (
ctx: MonitoringAccessCtx,
appName: string,
Expand All @@ -395,7 +452,11 @@ export const assertContainerMetricsServiceAccess = async (
message: "You are not authorized to access this monitored service",
});
}
const service = await findMonitoringServiceByAppName(appName);
const service =
(await findMonitoringServiceByAppName(appName)) ??
(serverId === undefined
? await findLocalContainerMonitoringService(appName)
: null);
if (
!service ||
service.environment?.project?.organizationId !==
Expand Down
2 changes: 1 addition & 1 deletion apps/dokploy/server/wss/docker-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export const setupDockerStatsMonitoringSocketServer = (
);
}
} catch {
ws.close();
ws.close(4003, "Not authorized");
return;
}

Expand Down