fix(sandbox): reap orphaned processes with docker-init, reject directory reads, buffer early websocket messages

This commit is contained in:
Mauricio Siu
2026-09-10 01:30:15 -06:00
parent fec13d604e
commit 0c38a498ef
6 changed files with 76 additions and 32 deletions
@@ -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");
});
+14
View File
@@ -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(
+47 -31
View File
@@ -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);
}
});
};
+2
View File
@@ -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,
+8
View File
@@ -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();