fix(monitoring): let members with service access see container monitoring (#5716)

* fix(monitoring): let members with service access see container monitoring

The Monitoring tab opened /listen-docker-stats-monitoring without a
serviceId, so the socket required the docker permission and closed with
"Not authorized" for members granted the service. Pass the serviceId from
every service page, as the Logs tab does.

With a serviceId, the socket now also checks that the requested container
belongs to that service (app name for applications and databases, project
or namespace label for compose and stacks), and never serves host stats.

Closes #5674

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(monitoring): reject unknown appType on the service-scoped stats path

appType comes from the URL, so an unexpected value skipped every
ownership filter and returned the first running container's stats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gourav Kumar Dhiman
2026-10-22 19:10:00 +05:30
committed by GitHub
parent ad222962b8
commit 9f6e82abf6
13 changed files with 149 additions and 5 deletions
@@ -15,9 +15,12 @@ vi.mock("@dokploy/server", () => ({
getAccessibleServerIds: mockGetAccessibleServerIds,
}));
vi.mock("@dokploy/server/db", () => ({ db: {} }));
import {
canAccessDockerOverWss,
canAccessTerminalOverWss,
isServiceContainerName,
} from "@/server/wss/authorize";
const USER = { id: "user-1" };
@@ -78,6 +81,42 @@ describe("canAccessDockerOverWss", () => {
});
});
describe("isServiceContainerName", () => {
it("matches an application or database only by its exact appName", () => {
expect(isServiceContainerName("web-abc", "application", "web-abc")).toBe(
true,
);
expect(isServiceContainerName("other-xyz", "application", "web-abc")).toBe(
false,
);
// Service access must not unlock the host-wide "dokploy" stats.
expect(isServiceContainerName("dokploy", "application", "web-abc")).toBe(
false,
);
});
it("matches stack tasks inside the service's namespace only", () => {
expect(
isServiceContainerName("stack-abc_web.1.task", "stack", "stack-abc"),
).toBe(true);
expect(
isServiceContainerName("stack-abcdef_web.1.task", "stack", "stack-abc"),
).toBe(false);
});
it("leaves compose containers to the project label filter", () => {
expect(
isServiceContainerName("custom-name", "docker-compose", "compose-abc"),
).toBe(true);
});
it("rejects an unknown appType", () => {
// The socket would apply no ownership filter for it.
expect(isServiceContainerName("web-abc", "invalid", "web-abc")).toBe(false);
expect(isServiceContainerName("web-abc", "", "web-abc")).toBe(false);
});
});
describe("canAccessTerminalOverWss", () => {
it("denies the local host terminal to a plain member", async () => {
mockFindMember.mockResolvedValue({ role: "member" });
@@ -27,12 +27,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ComposeFreeMonitoring = ({
appName,
appType = "stack",
serverId,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -123,6 +125,7 @@ export const ComposeFreeMonitoring = ({
<ContainerFreeMonitoring
appName={containerAppName || ""}
appType={appType}
serviceId={serviceId}
/>
</CardContent>
</>
@@ -46,6 +46,7 @@ const defaultData = {
interface Props {
appName: string;
appType?: "application" | "stack" | "docker-compose";
serviceId?: string;
}
export interface DockerStats {
cpu: {
@@ -120,6 +121,7 @@ export const convertMemoryToBytes = (
export const ContainerFreeMonitoring = ({
appName,
appType = "application",
serviceId,
}: Props) => {
const { data } = api.application.readAppMonitoring.useQuery(
{ appName },
@@ -171,7 +173,11 @@ export const ContainerFreeMonitoring = ({
if (!appName) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const params = new URLSearchParams({ appName, appType });
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?${params.toString()}`;
const ws = new WebSocket(wsUrl);
ws.onmessage = (e) => {
@@ -205,7 +211,7 @@ export const ContainerFreeMonitoring = ({
};
return () => ws.close();
}, [appName]);
}, [appName, serviceId]);
return (
<div className="rounded-xl bg-background flex flex-col gap-4">
@@ -345,6 +345,7 @@ const Service = (
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
serviceId={data?.applicationId}
/>
</div>
{/* )} */}
@@ -381,6 +381,7 @@ const Service = (
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
/>
{/* </div> */}
{/* )} */}
@@ -268,6 +268,7 @@ const Libsql = (
<div> */}
<ContainerFreeMonitoring
appName={data?.appName || ""}
serviceId={data?.libsqlId}
/>
{/* </div> */}
{/* )} */}
@@ -296,6 +296,7 @@ const Mariadb = (
<div> */}
<ContainerFreeMonitoring
appName={data?.appName || ""}
serviceId={data?.mariadbId}
/>
{/* </div> */}
{/* )} */}
@@ -296,6 +296,7 @@ const Mongo = (
<div> */}
<ContainerFreeMonitoring
appName={data?.appName || ""}
serviceId={data?.mongoId}
/>
{/* </div> */}
{/* )} */}
@@ -275,6 +275,7 @@ const MySql = (
<>
<ContainerFreeMonitoring
appName={data?.appName || ""}
serviceId={data?.mysqlId}
/>
</>
)}
@@ -283,6 +283,7 @@ const Postgresql = (
<>
<ContainerFreeMonitoring
appName={data?.appName || ""}
serviceId={data?.postgresId}
/>
</>
)}
@@ -294,6 +294,7 @@ const Redis = (
<div> */}
<ContainerFreeMonitoring
appName={data?.appName || ""}
serviceId={data?.redisId}
/>
{/* </div> */}
{/* )} */}
+61
View File
@@ -1,4 +1,5 @@
import { getAccessibleServerIds } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {
checkServiceAccess,
findMemberByUserId,
@@ -59,6 +60,66 @@ export const canAccessDockerOverWss = async (
return true;
};
// Returns the appName of the service with this id, whichever service type it
// is. Service-scoped sockets use it to check that the requested container
// belongs to the service the caller was authorized for.
export const findServiceAppName = async (
serviceId: string,
): Promise<string | null> => {
const columns = { appName: true } as const;
const results = await Promise.all([
db.query.applications.findFirst({
columns,
where: (t, { eq }) => eq(t.applicationId, serviceId),
}),
db.query.compose.findFirst({
columns,
where: (t, { eq }) => eq(t.composeId, serviceId),
}),
db.query.postgres.findFirst({
columns,
where: (t, { eq }) => eq(t.postgresId, serviceId),
}),
db.query.mysql.findFirst({
columns,
where: (t, { eq }) => eq(t.mysqlId, serviceId),
}),
db.query.mariadb.findFirst({
columns,
where: (t, { eq }) => eq(t.mariadbId, serviceId),
}),
db.query.mongo.findFirst({
columns,
where: (t, { eq }) => eq(t.mongoId, serviceId),
}),
db.query.redis.findFirst({
columns,
where: (t, { eq }) => eq(t.redisId, serviceId),
}),
db.query.libsql.findFirst({
columns,
where: (t, { eq }) => eq(t.libsqlId, serviceId),
}),
]);
return results.find(Boolean)?.appName ?? null;
};
// Whether a stats request for `appName` targets the service named
// `serviceAppName`. Applications and databases run as one swarm service named
// after the app; stack tasks are namespaced "<stack>_<service>". Compose
// container names are free-form, so the stats socket also filters compose and
// stack containers by their project/namespace label. `appType` comes from the
// URL, so any other value is rejected.
export const isServiceContainerName = (
appName: string,
appType: string,
serviceAppName: string,
): boolean => {
if (appType === "application") return appName === serviceAppName;
if (appType === "stack") return appName.startsWith(`${serviceAppName}_`);
return appType === "docker-compose";
};
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
// host terminal is a root shell on the control-plane host, so it is restricted
// to owner/admin. A remote server terminal needs server access plus
+30 -3
View File
@@ -10,7 +10,11 @@ import {
} from "@dokploy/server";
import { quote } from "shell-quote";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
import {
canAccessDockerOverWss,
findServiceAppName,
isServiceContainerName,
} from "./authorize";
type AppType = "application" | "stack" | "docker-compose";
@@ -110,10 +114,25 @@ export const setupDockerStatsMonitoringSocketServer = (
ws.close(4003, "Not authorized");
return;
}
// Service access only covers that service's containers, so pin the
// request to them instead of trusting appName.
const serviceAppName = serviceId
? await findServiceAppName(serviceId)
: null;
if (
serviceId &&
(!serviceAppName ||
!isServiceContainerName(appName, appType, serviceAppName))
) {
ws.close(4003, "Not authorized");
return;
}
const intervalId = setInterval(async () => {
try {
// Special case: when monitoring "dokploy", get host system stats instead of container stats
if (appName === "dokploy") {
if (appName === "dokploy" && !serviceId) {
const stat = await getHostSystemStats();
await recordAdvancedStats(stat, appName);
@@ -133,10 +152,18 @@ export const setupDockerStatsMonitoringSocketServer = (
label: [`com.docker.swarm.service.name=${appName}`],
}),
...(appType === "stack" && {
label: [`com.docker.swarm.task.name=${appName}`],
label: [
`com.docker.swarm.task.name=${appName}`,
...(serviceAppName
? [`com.docker.stack.namespace=${serviceAppName}`]
: []),
],
}),
...(appType === "docker-compose" && {
name: [appName],
...(serviceAppName && {
label: [`com.docker.compose.project=${serviceAppName}`],
}),
}),
};