fix(wss): bind container terminal to the service it was opened from

This commit is contained in:
Narciso
2026-10-21 22:18:25 -04:00
parent e76283bc09
commit 99e4f1c8dc
4 changed files with 151 additions and 1 deletions
@@ -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);
});
});
-1
View File
@@ -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);