mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-21 22:05:23 +02:00
Reapply "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 56169f3278.
This commit is contained in:
@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// Mock the permission + server helpers the wss authorizer composes.
|
||||
const mockHasPermission = vi.hoisted(() => vi.fn());
|
||||
const mockFindMember = vi.hoisted(() => vi.fn());
|
||||
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
findMemberByUserId: mockFindMember,
|
||||
checkServiceAccess: mockCheckServiceAccess,
|
||||
}));
|
||||
|
||||
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
|
||||
@@ -52,6 +54,22 @@ describe("canAccessDockerOverWss", () => {
|
||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies when the container belongs to a service the caller cannot access", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows when the caller has access to the container's service", async () => {
|
||||
mockHasPermission.mockResolvedValue(true);
|
||||
mockCheckServiceAccess.mockResolvedValue(undefined);
|
||||
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canAccessTerminalOverWss", () => {
|
||||
|
||||
@@ -206,6 +206,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
serviceId={data?.composeId}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -10,12 +10,14 @@ interface Props {
|
||||
id: string;
|
||||
containerId?: string;
|
||||
serverId?: string;
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const DockerTerminal: React.FC<Props> = ({
|
||||
id,
|
||||
containerId,
|
||||
serverId,
|
||||
serviceId,
|
||||
}) => {
|
||||
const termRef = useRef(null);
|
||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||
@@ -38,7 +40,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
const addonFit = new FitAddon();
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
|
||||
)}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.libsqlId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
||||
))}
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mariadbId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mongoId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -228,6 +228,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.mysqlId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -234,6 +234,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.postgresId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -229,6 +229,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serviceId={data?.redisId}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -40,6 +40,7 @@ interface Props {
|
||||
children?: React.ReactNode;
|
||||
serverId?: string;
|
||||
appType?: "stack" | "docker-compose";
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
export const DockerTerminalModal = ({
|
||||
@@ -47,6 +48,7 @@ export const DockerTerminalModal = ({
|
||||
appName,
|
||||
serverId,
|
||||
appType,
|
||||
serviceId,
|
||||
}: Props) => {
|
||||
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
@@ -131,6 +133,7 @@ export const DockerTerminalModal = ({
|
||||
serverId={serverId || ""}
|
||||
id="terminal"
|
||||
containerId={containerId || "select-a-container"}
|
||||
serviceId={serviceId}
|
||||
/>
|
||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAccessibleServerIds } from "@dokploy/server";
|
||||
import {
|
||||
checkServiceAccess,
|
||||
findMemberByUserId,
|
||||
hasPermission,
|
||||
} from "@dokploy/server/services/permission";
|
||||
@@ -22,6 +23,7 @@ export const canAccessDockerOverWss = async (
|
||||
user: WssUser,
|
||||
session: WssSession,
|
||||
serverId?: string | null,
|
||||
serviceId?: string | null,
|
||||
): Promise<boolean> => {
|
||||
if (!user || !session?.activeOrganizationId) return false;
|
||||
|
||||
@@ -36,6 +38,18 @@ export const canAccessDockerOverWss = async (
|
||||
if (!accessible.has(serverId)) return false;
|
||||
}
|
||||
|
||||
// When the container belongs to a known Dokploy service (opened from a
|
||||
// service page), enforce service-level access too. Container terminals
|
||||
// opened from the generic Docker overview have no serviceId and fall back to
|
||||
// the docker-permission + server checks above.
|
||||
if (serviceId) {
|
||||
try {
|
||||
await checkServiceAccess(ctx, serviceId, "read");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
const since = url.searchParams.get("since") ?? "all";
|
||||
const serverId = url.searchParams.get("serverId");
|
||||
const runType = url.searchParams.get("runType");
|
||||
const serviceId = url.searchParams.get("serviceId");
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!containerId) {
|
||||
@@ -75,7 +76,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await canAccessDockerOverWss(user, session, serverId))) {
|
||||
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
const containerId = url.searchParams.get("containerId");
|
||||
const activeWay = url.searchParams.get("activeWay");
|
||||
const serverId = url.searchParams.get("serverId");
|
||||
const serviceId = url.searchParams.get("serviceId");
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!containerId) {
|
||||
@@ -60,7 +61,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await canAccessDockerOverWss(user, session, serverId))) {
|
||||
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const setupDockerStatsMonitoringSocketServer = (
|
||||
| "application"
|
||||
| "stack"
|
||||
| "docker-compose";
|
||||
const serviceId = url.searchParams.get("serviceId");
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!appName) {
|
||||
@@ -57,7 +58,7 @@ export const setupDockerStatsMonitoringSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await canAccessDockerOverWss(user, session))) {
|
||||
if (!(await canAccessDockerOverWss(user, session, null, serviceId))) {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user