diff --git a/apps/dokploy/__test__/sandbox/container.test.ts b/apps/dokploy/__test__/sandbox/container.test.ts index 5f107a9ae..248ecb200 100644 --- a/apps/dokploy/__test__/sandbox/container.test.ts +++ b/apps/dokploy/__test__/sandbox/container.test.ts @@ -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"); }); diff --git a/apps/dokploy/__test__/sandbox/tar.test.ts b/apps/dokploy/__test__/sandbox/tar.test.ts index 70a2c1051..9b2618463 100644 --- a/apps/dokploy/__test__/sandbox/tar.test.ts +++ b/apps/dokploy/__test__/sandbox/tar.test.ts @@ -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( diff --git a/apps/dokploy/server/wss/sandbox-exec.ts b/apps/dokploy/server/wss/sandbox-exec.ts index b536daebe..f4d9ef7bc 100644 --- a/apps/dokploy/server/wss/sandbox-exec.ts +++ b/apps/dokploy/server/wss/sandbox-exec.ts @@ -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; 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); + } }); }; diff --git a/docs/sandboxes.md b/docs/sandboxes.md index 01f9a6a8f..15a444ae2 100644 --- a/docs/sandboxes.md +++ b/docs/sandboxes.md @@ -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 diff --git a/packages/server/src/utils/sandbox/container.ts b/packages/server/src/utils/sandbox/container.ts index c7da55e5d..4e07655d3 100644 --- a/packages/server/src/utils/sandbox/container.ts +++ b/packages/server/src/utils/sandbox/container.ts @@ -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, diff --git a/packages/server/src/utils/sandbox/tar.ts b/packages/server/src/utils/sandbox/tar.ts index 36bcbba8e..d10df997b 100644 --- a/packages/server/src/utils/sandbox/tar.ts +++ b/packages/server/src/utils/sandbox/tar.ts @@ -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();