Skip to content

Chunked uploading - MOVE with Content-Length: 0 results in 0bytes files in Object Storage #63504

Description

@susnux

A related finding on this same code path, with a second trigger that does not involve chunked transfer encoding — and which fastcgi_request_buffering on does not fix.

Symptom: uploading a large file through the web UI in Safari (26.6, macOS) stores a 0-byte file. Every request reports success — MKCOL 201, all five chunk PUTs 201, assembly MOVE .../.file 201 — and nothing is logged. The same file in Chrome uploads correctly. Server: 33.0.7, nginx + php-fpm, S3 (MinIO) primary storage, fastcgi_request_buffering on.

Cause: on the assembly step, File::put() takes the expected write size from the MOVE request's own Content-Length — a header that describes the MOVE (which carries no body), not the assembled stream being written:

apps/dav/lib/Connector/Sabre/File.php (33.0.7, lines 209-223)

$lengthHeader = $this->request->getHeader('content-length');
$expected = $lengthHeader !== '' ? (int)$lengthHeader : null;
...
$count = $partStorage->writeStream($internalPartPath, $wrappedData, $expected);

Safari sends Content-Length: 0 on the MOVE. Chrome omits the header entirely. Since "0" !== '', Safari yields $expected = 0 while Chrome yields null. That matters because ObjectStoreStorage::writeStream() only determines the real size when it was not given one:

lib/private/Files/ObjectStore/ObjectStoreStorage.php

public function writeStream(string $path, $stream, ?int $size = null): int {
    if ($size === null) {
        $stats = fstat($stream);
        if (is_array($stats) && isset($stats['size'])) {
            $size = $stats['size'];
        }
    }

So Safari's 0 skips the fstat() fallback and 0 bytes are written, reported as 201. Chrome's null runs the fallback and gets the correct length.

This appears to be specific to object-storage primary storage: OC\Files\Storage\Local::writeStream() ignores the $size argument altogether and just does file_put_contents(), so a local-storage install should not be affected.

Reproduction with plain curl — no browser involved. 102395904-byte file, five chunks (4×20971520 + 18509824), fastcgi_request_buffering on throughout. The single variable is the Content-Length header on the MOVE:

chunk PUTs Content-Length: 0 on MOVE result
sequential no 102395904 bytes — correct
sequential yes 0 bytes, MOVE 201
parallel no 102395904 bytes — correct
parallel yes 0 bytes, MOVE 201
ID=repro
curl -X MKCOL "$BASE/remote.php/dav/uploads/$USER/$ID"
n=1; for p in part.*; do curl -X PUT --data-binary @$p "$BASE/remote.php/dav/uploads/$USER/$ID/$n"; n=$((n+1)); done
# succeeds:
curl -X MOVE -H "Destination: $BASE/remote.php/dav/files/$USER/out.pdf" -H "OC-Total-Length: 102395904" \
     "$BASE/remote.php/dav/uploads/$USER/$ID/.file"
# stores 0 bytes, returns 201:
curl -X MOVE -H "Destination: $BASE/remote.php/dav/files/$USER/out.pdf" -H "OC-Total-Length: 102395904" \
     -H "Content-Length: 0" "$BASE/remote.php/dav/uploads/$USER/$ID/.file"

Workaround without patching PHP. MOVE never carries a body, so the length can be blanked for that method in nginx:

map $request_method $nc_content_length {
    default $content_length;
    MOVE    "";
}

and inside the PHP location, after include fastcgi_params; (with duplicate FastCGI params the last one wins):

fastcgi_param CONTENT_LENGTH $nc_content_length;
fastcgi_param HTTP_CONTENT_LENGTH $nc_content_length;

Both names are required because Request::getHeader() checks HTTP_CONTENT_LENGTH first and only then falls back to CONTENT_LENGTH. Verified: all four rows above become correct, a plain 20971520-byte PUT still stores 20971520, and a genuinely empty file still stores 0 bytes.

Possible fix — treat a zero-length header as absent, so the stream's own size is used:

$expected = ($lengthHeader !== '' && (int)$lengthHeader > 0) ? (int)$lengthHeader : null;

A real 0-byte PUT then takes the fstat() path and still writes 0 bytes, so that case is unchanged. Arguably the assembly path should not consult the request's Content-Length at all, since it never describes the stream being assembled.

Happy to open this as a separate issue if it's considered distinct from the chunked-encoding case reported here.

Originally posted by @mosi-kha in #7995

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions