mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-23 17:05:26 +00:00
fix(wss): bind container terminal to the service it was opened from
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFindApp = vi.hoisted(() => vi.fn());
|
||||
const mockExec = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const none = { findFirst: async () => undefined };
|
||||
return {
|
||||
db: {
|
||||
query: {
|
||||
applications: { findFirst: mockFindApp },
|
||||
compose: none,
|
||||
postgres: none,
|
||||
mysql: none,
|
||||
mariadb: none,
|
||||
mongo: none,
|
||||
redis: none,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: mockExec,
|
||||
execAsyncRemote: mockExec,
|
||||
}));
|
||||
|
||||
import { containerBelongsToService } from "@/server/wss/container-ownership";
|
||||
|
||||
const labels = (l: Record<string, string>) => ({ stdout: JSON.stringify(l) });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFindApp.mockResolvedValue({ appName: "app-mine-abc123" });
|
||||
});
|
||||
|
||||
describe("containerBelongsToService", () => {
|
||||
it("allows a container of the service", async () => {
|
||||
mockExec.mockResolvedValue(
|
||||
labels({ "com.docker.swarm.service.name": "app-mine-abc123" }),
|
||||
);
|
||||
expect(await containerBelongsToService("svc", "c1")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies a container of another service", async () => {
|
||||
mockExec.mockResolvedValue(
|
||||
labels({ "com.docker.swarm.service.name": "dokploy" }),
|
||||
);
|
||||
expect(await containerBelongsToService("svc", "c1")).toBe(false);
|
||||
});
|
||||
|
||||
it("denies containers without labels or when docker fails", async () => {
|
||||
mockExec.mockResolvedValue({ stdout: "null" });
|
||||
expect(await containerBelongsToService("svc", "c1")).toBe(false);
|
||||
mockExec.mockRejectedValue(new Error("No such object"));
|
||||
expect(await containerBelongsToService("svc", "c1")).toBe(false);
|
||||
});
|
||||
|
||||
it("denies an unknown service", async () => {
|
||||
mockFindApp.mockResolvedValue(undefined);
|
||||
expect(await containerBelongsToService("nope", "c1")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,6 @@ export const canAccessDockerOverWss = async (
|
||||
serverId?: string | null,
|
||||
serviceId?: string | null,
|
||||
): Promise<boolean> => {
|
||||
// return false;
|
||||
if (!user || !session?.activeOrganizationId) return false;
|
||||
|
||||
const ctx = buildCtx(user, session.activeOrganizationId);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
applications,
|
||||
compose,
|
||||
mariadb,
|
||||
mongo,
|
||||
mysql,
|
||||
postgres,
|
||||
redis,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
const OWNERSHIP_LABELS = [
|
||||
"com.docker.swarm.service.name",
|
||||
"com.docker.compose.project",
|
||||
"com.docker.stack.namespace",
|
||||
];
|
||||
|
||||
const findServiceAppName = async (serviceId: string) => {
|
||||
const [app, comp, pg, my, maria, mon, red] = await Promise.all([
|
||||
db.query.applications.findFirst({
|
||||
where: eq(applications.applicationId, serviceId),
|
||||
columns: { appName: true },
|
||||
}),
|
||||
db.query.compose.findFirst({
|
||||
where: eq(compose.composeId, serviceId),
|
||||
columns: { appName: true },
|
||||
}),
|
||||
db.query.postgres.findFirst({
|
||||
where: eq(postgres.postgresId, serviceId),
|
||||
columns: { appName: true },
|
||||
}),
|
||||
db.query.mysql.findFirst({
|
||||
where: eq(mysql.mysqlId, serviceId),
|
||||
columns: { appName: true },
|
||||
}),
|
||||
db.query.mariadb.findFirst({
|
||||
where: eq(mariadb.mariadbId, serviceId),
|
||||
columns: { appName: true },
|
||||
}),
|
||||
db.query.mongo.findFirst({
|
||||
where: eq(mongo.mongoId, serviceId),
|
||||
columns: { appName: true },
|
||||
}),
|
||||
db.query.redis.findFirst({
|
||||
where: eq(redis.redisId, serviceId),
|
||||
columns: { appName: true },
|
||||
}),
|
||||
]);
|
||||
return (app ?? comp ?? pg ?? my ?? maria ?? mon ?? red)?.appName;
|
||||
};
|
||||
|
||||
// Service access alone must not allow `docker exec` into other tenants' containers.
|
||||
export const containerBelongsToService = async (
|
||||
serviceId: string,
|
||||
containerId: string,
|
||||
serverId?: string | null,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const appName = await findServiceAppName(serviceId);
|
||||
if (!appName) return false;
|
||||
const command = quote([
|
||||
"docker",
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{json .Config.Labels}}",
|
||||
containerId,
|
||||
]);
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
const labels = JSON.parse(stdout.trim()) ?? {};
|
||||
return OWNERSHIP_LABELS.some((label) => labels[label] === appName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import { spawn } from "node-pty";
|
||||
import { Client } from "ssh2";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { canAccessDockerOverWss } from "./authorize";
|
||||
import { containerBelongsToService } from "./container-ownership";
|
||||
import {
|
||||
isValidContainerId,
|
||||
isValidShell,
|
||||
@@ -74,6 +75,13 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
serviceId &&
|
||||
!(await containerBelongsToService(serviceId, containerId, serverId))
|
||||
) {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (serverId) {
|
||||
const server = await findServerById(serverId);
|
||||
|
||||
Reference in New Issue
Block a user