Skip to content

[BUG] Chunked upload path for files over 1.5 GiB always fails with 400 — client sends form fields, server reads query #1232

Description

@l3gitpanda

Title

Files larger than 1.5 GiB fail immediately; the chunked upload client and server disagree on the request format

Platform

Desktop App - Windows

Server Installation Method

Proxmox (Community Scripts)

Version

2.7.1

CLI Installation Method

None

CLI Version

No response

Troubleshooting

  • I have examined logs and tried to find the issue
  • I have reviewed opened and closed issues
  • I have tried restarting the application
  • I have checked open issues and ensured this is not a duplicate

The Problem

Found while investigating bulk upload failures ( #1231 ). This one is independent and reproducible on its own with a single file.

The client switches to a chunked upload above 1.5 GiB (src/ui/api/ssh-file-operations-api.ts:444-497), building a multipart FormData body with no query string:

form.append("sessionId", sessionId);
form.append("path", path);
form.append("fileName", fileName);
form.append("chunkIndex", String(i));
form.append("totalChunks", String(totalChunks));
form.append("totalSize", String(file.size));
form.append("chunk", chunkBlob, fileName);

await getFileManagerApiForSession(sessionId)
  .postForm("/ssh/uploadFileChunk", form, { timeout: 0 });

The server reads only req.query (src/backend/hosts/file-manager/content-routes.ts:1538-1558):

const sessionId  = getRequiredQueryParam(req.query.sessionId as string);
const remotePath = getRequiredQueryParam(req.query.path as string);
const fileName   = getRequiredQueryParam(req.query.fileName as string);
const offset     = parseByteOffset(getRequiredQueryParam(req.query.offset as string));
const totalSize  = parseByteOffset(getRequiredQueryParam(req.query.totalSize as string));

if (!sessionId || !remotePath || !fileName) {
  req.resume();
  return res.status(400).json({ error: "Missing sessionId, path, or fileName" });
}

I ran the route's exact parsing logic against the client's exact FormData shape:

POST /ssh/uploadFileChunk  (multipart body, no query string)
  server saw req.query = {}
  -> HTTP 400 { error: 'Missing sessionId, path, or fileName' }

There are three separate contract mismatches:

  1. Body vs. query. The client sends fields in the multipart body; the server reads them from the query string. Guaranteed 400 on the first chunk, every time.
  2. chunkIndex vs. offset. The client sends a chunk index; the server expects a byte offset. Different protocols.
  3. Raw body piped into the file. The server pipes the request body straight into the SFTP write stream (content-routes.ts:1636). With a multipart body that would write --boundary delimiters and Content-Disposition headers into the destination file. The route is written for application/octet-stream (cf. express.raw({ limit: "5gb", type: "application/octet-stream" }) at index.ts:126), which is not what the client sends.

The client also ignores the nextOffset and complete fields the server returns, so there's no resume even in principle.

Net effect: every file over 1.5 GiB fails immediately, and the fallback specifically designed to rescue very large uploads is the one path that can never run.

How to Reproduce

  1. Open the file manager on any SSH host.
  2. Upload a single file larger than 1.5 GiB.
  3. It fails immediately. The server responds 400 {"error":"Missing sessionId, path, or fileName"} on the very first chunk.

Additional Context

There is no test coverage for this route — src/backend/tests/hosts/file-manager/ has no tests for uploadFileChunk, which is presumably why the mismatch went unnoticed.

Suggested fix

Pick one side of the contract and make both match:

  • Option A — client sends params as a query string with a real byte offset, and posts the chunk as a raw application/octet-stream body (matches the existing express.raw config and the server's req.pipe(writeStream)).
  • Option B — server parses the multipart body with busboy, the way uploadFileStream already does.

Either way, the client should then honour the returned nextOffset / complete so resume actually works.

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions