From 4c957c50b4ed9eaf639eb2f626b8306bfff8451d Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Mon, 15 Jun 2026 22:21:47 +0700 Subject: [PATCH] fix(file-manager): raise the upload route body limit, add a client-side size guard, and write uploaded content to containers in chunks to avoid oversized SSH commands. --- .../modals/ContainerFileManagerModal.tsx | 12 ++++ src/server/routes/containers.ts | 13 +++- .../ssh-services/docker-containers.ts | 72 ++++++++++++++++++- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/app/containers/components/modals/ContainerFileManagerModal.tsx b/src/app/containers/components/modals/ContainerFileManagerModal.tsx index bdd9922..2f809cc 100644 --- a/src/app/containers/components/modals/ContainerFileManagerModal.tsx +++ b/src/app/containers/components/modals/ContainerFileManagerModal.tsx @@ -41,6 +41,8 @@ interface ContainerFileManagerModalProps { onClose: () => void; } +const FILE_MANAGER_UPLOAD_MAX_BYTES = 6 * 1024 * 1024; + type PendingConfirmAction = { title: string; description: string; @@ -288,6 +290,16 @@ export default function ContainerFileManagerModal({ const uploadFileObject = useCallback( async (file: File) => { + if (file.size > FILE_MANAGER_UPLOAD_MAX_BYTES) { + setNotice({ + tone: "error", + message: `File is too large. Maximum upload size is ${formatBytes( + FILE_MANAGER_UPLOAD_MAX_BYTES, + )}.`, + }); + return; + } + setUploading(true); setNotice(null); try { diff --git a/src/server/routes/containers.ts b/src/server/routes/containers.ts index 3a5de0e..92c1e91 100644 --- a/src/server/routes/containers.ts +++ b/src/server/routes/containers.ts @@ -122,6 +122,9 @@ const ContainerRenamePathSchema = z.object({ newPath: z.string().min(1).max(2048), }); +const CONTAINER_UPLOAD_CONTENT_BASE64_MAX_LENGTH = 8_000_000; +const CONTAINER_UPLOAD_BODY_LIMIT_BYTES = 10 * 1024 * 1024; + const ContainerUploadFileSchema = z.object({ directoryPath: z.string().min(1).max(2048), fileName: z @@ -129,7 +132,10 @@ const ContainerUploadFileSchema = z.object({ .min(1) .max(255) .refine((value) => !value.includes("/")), - contentBase64: z.string().min(1).max(8_000_000), + contentBase64: z + .string() + .min(1) + .max(CONTAINER_UPLOAD_CONTENT_BASE64_MAX_LENGTH), }); function mapDockerStatus(status: string): ContainerStatus { @@ -2006,7 +2012,10 @@ export async function containerRoutes(app: FastifyInstance) { app.post( "/:id/file/upload", - { preHandler: containerWriteAccess }, + { + preHandler: containerWriteAccess, + bodyLimit: CONTAINER_UPLOAD_BODY_LIMIT_BYTES, + }, async (req, reply) => { const { id } = req.params as { id: string }; const body = ContainerUploadFileSchema.safeParse(req.body); diff --git a/src/server/services/ssh-services/docker-containers.ts b/src/server/services/ssh-services/docker-containers.ts index 473f629..4935ccf 100644 --- a/src/server/services/ssh-services/docker-containers.ts +++ b/src/server/services/ssh-services/docker-containers.ts @@ -22,6 +22,7 @@ const DOCKER_FILE_LIST_TIMEOUT_MS = 10_000; const DOCKER_FILE_READ_TIMEOUT_MS = 15_000; const DOCKER_FILE_WRITE_TIMEOUT_MS = 20_000; const DOCKER_FILE_DOWNLOAD_TIMEOUT_MS = 30_000; +const CONTAINER_FILE_UPLOAD_CHUNK_SIZE = 48_000; const DEPLOY_GIT_CLONE_TIMEOUT_MS = 10 * 60_000; const DEPLOY_BUILD_TIMEOUT_MS = 45 * 60_000; const DEPLOY_COMPOSE_TIMEOUT_MS = 45 * 60_000; @@ -1220,12 +1221,77 @@ export async function writeContainerFileBase64( filePath: string, contentBase64: string, ): Promise<{ path: string; size: number }> { - const script = [ + const uploadId = randomBytes(8).toString("hex"); + const tempBase64Path = `${filePath}.doktainer-upload-${uploadId}.b64`; + const tempFilePath = `${filePath}.doktainer-upload-${uploadId}.tmp`; + const prepareScript = [ `TARGET=${escapeShellArg(filePath)}`, + `TEMP_B64=${escapeShellArg(tempBase64Path)}`, + `TEMP_FILE=${escapeShellArg(tempFilePath)}`, 'PARENT=$(dirname "$TARGET")', 'if [ ! -d "$PARENT" ]; then echo "__ERROR__\tParent directory not found"; exit 22; fi', 'if [ -e "$TARGET" ] && [ ! -f "$TARGET" ]; then echo "__ERROR__\tOnly regular files can be overwritten"; exit 31; fi', - `printf %s ${escapeShellArg(contentBase64)} | base64 -d > "$TARGET"`, + 'rm -f "$TEMP_B64" "$TEMP_FILE"', + ': > "$TEMP_B64"', + 'printf "__OK__\tprepared\n"', + ].join("\n"); + + await execContainerShell( + server, + containerId, + prepareScript, + DOCKER_FILE_WRITE_TIMEOUT_MS, + ); + + try { + for ( + let offset = 0; + offset < contentBase64.length; + offset += CONTAINER_FILE_UPLOAD_CHUNK_SIZE + ) { + const chunk = contentBase64.slice( + offset, + offset + CONTAINER_FILE_UPLOAD_CHUNK_SIZE, + ); + const appendScript = [ + `TEMP_B64=${escapeShellArg(tempBase64Path)}`, + `printf %s ${escapeShellArg(chunk)} >> "$TEMP_B64"`, + 'printf "__OK__\tappended\n"', + ].join("\n"); + + await execContainerShell( + server, + containerId, + appendScript, + DOCKER_FILE_WRITE_TIMEOUT_MS, + ); + } + } catch (error) { + const cleanupScript = [ + `TEMP_B64=${escapeShellArg(tempBase64Path)}`, + `TEMP_FILE=${escapeShellArg(tempFilePath)}`, + 'rm -f "$TEMP_B64" "$TEMP_FILE"', + ].join("\n"); + try { + await execContainerShell( + server, + containerId, + cleanupScript, + DOCKER_FILE_WRITE_TIMEOUT_MS, + ); + } catch { + // Best-effort cleanup; surface the original upload failure. + } + throw error; + } + + const finalizeScript = [ + `TARGET=${escapeShellArg(filePath)}`, + `TEMP_B64=${escapeShellArg(tempBase64Path)}`, + `TEMP_FILE=${escapeShellArg(tempFilePath)}`, + 'trap \'rm -f "$TEMP_B64" "$TEMP_FILE"\' EXIT', + 'base64 -d "$TEMP_B64" > "$TEMP_FILE"', + 'mv "$TEMP_FILE" "$TARGET"', 'SIZE=$(stat -c "%s" "$TARGET" 2>/dev/null || stat -f "%z" "$TARGET" 2>/dev/null || echo "0")', 'printf "__OK__\t%s\n" "$SIZE"', ].join("\n"); @@ -1233,7 +1299,7 @@ export async function writeContainerFileBase64( const stdout = await execContainerShell( server, containerId, - script, + finalizeScript, DOCKER_FILE_WRITE_TIMEOUT_MS, ); const okLine = stdout