mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-04 19:22:16 +00: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.
|
// Mock the permission + server helpers the wss authorizer composes.
|
||||||
const mockHasPermission = vi.hoisted(() => vi.fn());
|
const mockHasPermission = vi.hoisted(() => vi.fn());
|
||||||
const mockFindMember = vi.hoisted(() => vi.fn());
|
const mockFindMember = vi.hoisted(() => vi.fn());
|
||||||
|
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("@dokploy/server/services/permission", () => ({
|
vi.mock("@dokploy/server/services/permission", () => ({
|
||||||
hasPermission: mockHasPermission,
|
hasPermission: mockHasPermission,
|
||||||
findMemberByUserId: mockFindMember,
|
findMemberByUserId: mockFindMember,
|
||||||
|
checkServiceAccess: mockCheckServiceAccess,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
|
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
|
||||||
@@ -52,6 +54,22 @@ describe("canAccessDockerOverWss", () => {
|
|||||||
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
|
||||||
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
|
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", () => {
|
describe("canAccessTerminalOverWss", () => {
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
appType={data?.composeType || "docker-compose"}
|
appType={data?.composeType || "docker-compose"}
|
||||||
|
serviceId={data?.composeId}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ interface Props {
|
|||||||
id: string;
|
id: string;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
serviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DockerTerminal: React.FC<Props> = ({
|
export const DockerTerminal: React.FC<Props> = ({
|
||||||
id,
|
id,
|
||||||
containerId,
|
containerId,
|
||||||
serverId,
|
serverId,
|
||||||
|
serviceId,
|
||||||
}) => {
|
}) => {
|
||||||
const termRef = useRef(null);
|
const termRef = useRef(null);
|
||||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||||
@@ -38,7 +40,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
const addonFit = new FitAddon();
|
const addonFit = new FitAddon();
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
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);
|
const ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
|
serviceId={data?.libsqlId}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
|
|||||||
))}
|
))}
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
|
serviceId={data?.mariadbId}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
|
serviceId={data?.mongoId}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
|
serviceId={data?.mysqlId}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
|
serviceId={data?.postgresId}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -229,6 +229,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
|
serviceId={data?.redisId}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface Props {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
appType?: "stack" | "docker-compose";
|
appType?: "stack" | "docker-compose";
|
||||||
|
serviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DockerTerminalModal = ({
|
export const DockerTerminalModal = ({
|
||||||
@@ -47,6 +48,7 @@ export const DockerTerminalModal = ({
|
|||||||
appName,
|
appName,
|
||||||
serverId,
|
serverId,
|
||||||
appType,
|
appType,
|
||||||
|
serviceId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
|
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
{
|
{
|
||||||
@@ -131,6 +133,7 @@ export const DockerTerminalModal = ({
|
|||||||
serverId={serverId || ""}
|
serverId={serverId || ""}
|
||||||
id="terminal"
|
id="terminal"
|
||||||
containerId={containerId || "select-a-container"}
|
containerId={containerId || "select-a-container"}
|
||||||
|
serviceId={serviceId}
|
||||||
/>
|
/>
|
||||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||||
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
|
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getAccessibleServerIds } from "@dokploy/server";
|
import { getAccessibleServerIds } from "@dokploy/server";
|
||||||
import {
|
import {
|
||||||
|
checkServiceAccess,
|
||||||
findMemberByUserId,
|
findMemberByUserId,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
} from "@dokploy/server/services/permission";
|
} from "@dokploy/server/services/permission";
|
||||||
@@ -22,6 +23,7 @@ export const canAccessDockerOverWss = async (
|
|||||||
user: WssUser,
|
user: WssUser,
|
||||||
session: WssSession,
|
session: WssSession,
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
|
serviceId?: string | null,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!user || !session?.activeOrganizationId) return false;
|
if (!user || !session?.activeOrganizationId) return false;
|
||||||
|
|
||||||
@@ -36,6 +38,18 @@ export const canAccessDockerOverWss = async (
|
|||||||
if (!accessible.has(serverId)) return false;
|
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;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
const since = url.searchParams.get("since") ?? "all";
|
const since = url.searchParams.get("since") ?? "all";
|
||||||
const serverId = url.searchParams.get("serverId");
|
const serverId = url.searchParams.get("serverId");
|
||||||
const runType = url.searchParams.get("runType");
|
const runType = url.searchParams.get("runType");
|
||||||
|
const serviceId = url.searchParams.get("serviceId");
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
@@ -75,7 +76,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await canAccessDockerOverWss(user, session, serverId))) {
|
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
|
||||||
ws.close(4003, "Not authorized");
|
ws.close(4003, "Not authorized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
const containerId = url.searchParams.get("containerId");
|
const containerId = url.searchParams.get("containerId");
|
||||||
const activeWay = url.searchParams.get("activeWay");
|
const activeWay = url.searchParams.get("activeWay");
|
||||||
const serverId = url.searchParams.get("serverId");
|
const serverId = url.searchParams.get("serverId");
|
||||||
|
const serviceId = url.searchParams.get("serviceId");
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
@@ -60,7 +61,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await canAccessDockerOverWss(user, session, serverId))) {
|
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
|
||||||
ws.close(4003, "Not authorized");
|
ws.close(4003, "Not authorized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export const setupDockerStatsMonitoringSocketServer = (
|
|||||||
| "application"
|
| "application"
|
||||||
| "stack"
|
| "stack"
|
||||||
| "docker-compose";
|
| "docker-compose";
|
||||||
|
const serviceId = url.searchParams.get("serviceId");
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
if (!appName) {
|
if (!appName) {
|
||||||
@@ -57,7 +58,7 @@ export const setupDockerStatsMonitoringSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(await canAccessDockerOverWss(user, session))) {
|
if (!(await canAccessDockerOverWss(user, session, null, serviceId))) {
|
||||||
ws.close(4003, "Not authorized");
|
ws.close(4003, "Not authorized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user