Reapply "feat(security): enforce service-level access on docker WebSocket handlers"

This reverts commit 56169f3278.
This commit is contained in:
Mauricio Siu
2026-07-20 00:35:40 -06:00
parent 56169f3278
commit 1bc76e9e5b
14 changed files with 51 additions and 4 deletions
@@ -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()}>
+14
View File
@@ -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;
} }
+2 -1
View File
@@ -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;
} }