mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-19 23:21:36 +00:00
fix(sandbox): reap orphaned processes with docker-init, reject directory reads, buffer early websocket messages
This commit is contained in:
@@ -40,10 +40,11 @@ describe("buildSandboxContainerOptions", () => {
|
||||
expect(options.User).toBe("1000:1000");
|
||||
});
|
||||
|
||||
it("keeps the container alive with a portable command", () => {
|
||||
it("keeps the container alive with a portable command under an init", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.Entrypoint).toEqual([""]);
|
||||
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
|
||||
expect(options.HostConfig?.Init).toBe(true);
|
||||
expect(options.WorkingDir).toBe("/home/user");
|
||||
});
|
||||
|
||||
|
||||
@@ -122,6 +122,20 @@ describe("extractSandboxFile", () => {
|
||||
).rejects.toBeInstanceOf(SandboxFileTooLargeError);
|
||||
});
|
||||
|
||||
it("rejects a directory archive even when it contains files", async () => {
|
||||
const dir = await buildSandboxDirectoryTar({ name: "proj" });
|
||||
const file = await buildSandboxFileTar({
|
||||
name: "proj/main.py",
|
||||
content: Buffer.from("print(1)"),
|
||||
});
|
||||
// tar-stream pads each archive with two zero blocks; strip the first
|
||||
// archive's end-of-archive marker so the entries are read as one tar.
|
||||
const combined = Buffer.concat([dir.subarray(0, dir.length - 1024), file]);
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(combined), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
|
||||
});
|
||||
|
||||
it("rejects archives without a regular file", async () => {
|
||||
const tar = await buildSandboxDirectoryTar({ name: "dir" });
|
||||
await expect(
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
validateRequest,
|
||||
} from "@dokploy/server";
|
||||
import { checkServicePermissionAndAccess } from "@dokploy/server/services/permission";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { type RawData, type WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { apiExecSandbox, SANDBOX_DEFAULTS } from "@/server/db/schema";
|
||||
|
||||
@@ -46,37 +46,8 @@ export const setupSandboxExecWebSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || !session?.activeOrganizationId) {
|
||||
ws.close(4001, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
user: { id: user.id },
|
||||
session: { activeOrganizationId: session.activeOrganizationId },
|
||||
};
|
||||
|
||||
try {
|
||||
await checkServicePermissionAndAccess(ctx, sandboxId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const sandbox = await findSandboxById(sandboxId);
|
||||
if (
|
||||
sandbox.environment.project.organizationId !==
|
||||
session.activeOrganizationId
|
||||
) {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
|
||||
let running = false;
|
||||
send(ws, { type: "ready", sandboxId });
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
const handle = async (raw: RawData) => {
|
||||
let parsed: z.infer<typeof execMessage>;
|
||||
try {
|
||||
parsed = execMessage.parse(JSON.parse(raw.toString()));
|
||||
@@ -115,6 +86,51 @@ export const setupSandboxExecWebSocketServer = (
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Clients may send as soon as the socket opens, before authorization
|
||||
// finishes; queue those messages instead of dropping them.
|
||||
let authorized = false;
|
||||
const pending: RawData[] = [];
|
||||
ws.on("message", (raw) => {
|
||||
if (!authorized) {
|
||||
pending.push(raw);
|
||||
return;
|
||||
}
|
||||
void handle(raw);
|
||||
});
|
||||
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || !session?.activeOrganizationId) {
|
||||
ws.close(4001, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
const ctx = {
|
||||
user: { id: user.id },
|
||||
session: { activeOrganizationId: session.activeOrganizationId },
|
||||
};
|
||||
|
||||
try {
|
||||
await checkServicePermissionAndAccess(ctx, sandboxId, {
|
||||
deployment: ["create"],
|
||||
});
|
||||
const sandbox = await findSandboxById(sandboxId);
|
||||
if (
|
||||
sandbox.environment.project.organizationId !==
|
||||
session.activeOrganizationId
|
||||
) {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
ws.close(4003, "Not authorized");
|
||||
return;
|
||||
}
|
||||
|
||||
authorized = true;
|
||||
send(ws, { type: "ready", sandboxId });
|
||||
for (const raw of pending.splice(0)) {
|
||||
void handle(raw);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -174,6 +174,8 @@ The dashboard console on the sandbox page uses this protocol.
|
||||
Each sandbox container is created with:
|
||||
|
||||
- `CapDrop: ["ALL"]` and `SecurityOpt: ["no-new-privileges"]`.
|
||||
- `Init: true` (docker-init/tini as PID 1) so processes orphaned by a killed
|
||||
command are reaped instead of lingering as zombies.
|
||||
- CPU (`NanoCpus`), memory (`Memory` = `MemorySwap`, so no swap) and
|
||||
`PidsLimit` quotas; `RestartPolicy: no`; JSON log capped at 10 MiB.
|
||||
- A non-root user for the built-in templates (`1000:1000`); the workdir is
|
||||
|
||||
@@ -68,6 +68,9 @@ export const buildSandboxContainerOptions = (
|
||||
[SANDBOX_ENVIRONMENT_LABEL]: spec.environmentId,
|
||||
},
|
||||
HostConfig: {
|
||||
// docker-init (tini) as PID 1 reaps the orphans left behind when a
|
||||
// timed-out command's shell is killed; `tail` would leave zombies.
|
||||
Init: true,
|
||||
NanoCpus: Math.round(spec.cpu * 1e9),
|
||||
Memory: memory,
|
||||
MemorySwap: memory,
|
||||
|
||||
@@ -90,7 +90,15 @@ export const extractSandboxFile = (
|
||||
reject(error);
|
||||
};
|
||||
|
||||
let first = true;
|
||||
extractor.on("entry", (header, entryStream, next) => {
|
||||
// Docker returns the requested path as the first entry; a directory
|
||||
// tar would otherwise leak its first file as if it were the target.
|
||||
if (first && header.type !== "file") {
|
||||
fail(new SandboxNotRegularFileError());
|
||||
return;
|
||||
}
|
||||
first = false;
|
||||
if (found || header.type !== "file") {
|
||||
entryStream.on("end", next);
|
||||
entryStream.resume();
|
||||
|
||||
Reference in New Issue
Block a user